From 491fd049b03c659e9528f7c63e31ad58597585b7 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 1 Nov 2025 14:26:52 +0100 Subject: [PATCH] refactor(all)!: clean out project for v2 --- .cargo/config.toml | 23 - .github/workflows/macos-check.yml | 61 - .gitignore | 107 - .pre-commit-config.yaml | 35 - .woodpecker.yml | 197 - CHANGELOG.md | 144 - CLAUDE.md | 309 - CODE_OF_CONDUCT.md | 121 - CONTRIBUTING.md | 126 - Cargo.toml | 90 - LICENSE | 661 - PKGBUILD | 49 - PROVIDER_MANAGER_OPTIMIZATIONS.md | 400 - README.md | 234 - SECURITY.md | 40 - SQLX_MIGRATION_GUIDE.md | 197 - agents.md | 10 - config.toml | 35 - crates/mcp/client/Cargo.toml | 12 - crates/mcp/client/src/lib.rs | 17 - crates/mcp/code-server/Cargo.toml | 22 - crates/mcp/code-server/src/lib.rs | 186 - crates/mcp/code-server/src/sandbox.rs | 250 - crates/mcp/code-server/src/tools.rs | 417 - crates/mcp/llm-server/Cargo.toml | 16 - crates/mcp/llm-server/src/main.rs | 598 - crates/mcp/prompt-server/Cargo.toml | 21 - crates/mcp/prompt-server/src/lib.rs | 415 - .../mcp/prompt-server/templates/example.yaml | 3 - crates/mcp/server/Cargo.toml | 12 - crates/mcp/server/src/main.rs | 246 - crates/owlen-cli/Cargo.toml | 63 - crates/owlen-cli/README.md | 15 - crates/owlen-cli/build.rs | 31 - crates/owlen-cli/src/agent_main.rs | 285 - crates/owlen-cli/src/bootstrap.rs | 340 - crates/owlen-cli/src/code_main.rs | 16 - crates/owlen-cli/src/commands/cloud.rs | 470 - crates/owlen-cli/src/commands/mod.rs | 7 - crates/owlen-cli/src/commands/providers.rs | 800 - crates/owlen-cli/src/commands/repo.rs | 203 - crates/owlen-cli/src/commands/security.rs | 61 - crates/owlen-cli/src/commands/tools.rs | 110 - crates/owlen-cli/src/lib.rs | 8 - crates/owlen-cli/src/main.rs | 489 - crates/owlen-cli/src/mcp.rs | 260 - crates/owlen-cli/tests/agent_tests.rs | 275 - crates/owlen-core/Cargo.toml | 52 - crates/owlen-core/README.md | 12 - .../migrations/0001_create_conversations.sql | 12 - .../migrations/0002_create_secure_items.sql | 7 - crates/owlen-core/src/agent.rs | 478 - crates/owlen-core/src/agent_registry.rs | 462 - crates/owlen-core/src/automation/mod.rs | 9 - crates/owlen-core/src/automation/repo.rs | 943 - crates/owlen-core/src/config.rs | 2944 --- crates/owlen-core/src/consent.rs | 312 - crates/owlen-core/src/conversation.rs | 448 - crates/owlen-core/src/credentials.rs | 108 - crates/owlen-core/src/encryption.rs | 265 - crates/owlen-core/src/facade/llm_client.rs | 32 - crates/owlen-core/src/facade/mod.rs | 1 - crates/owlen-core/src/formatting.rs | 112 - crates/owlen-core/src/github.rs | 258 - crates/owlen-core/src/input.rs | 223 - crates/owlen-core/src/lib.rs | 123 - crates/owlen-core/src/llm/mod.rs | 337 - crates/owlen-core/src/mcp.rs | 188 - crates/owlen-core/src/mcp/client.rs | 21 - crates/owlen-core/src/mcp/factory.rs | 194 - crates/owlen-core/src/mcp/failover.rs | 324 - crates/owlen-core/src/mcp/permission.rs | 229 - crates/owlen-core/src/mcp/presets.rs | 446 - crates/owlen-core/src/mcp/protocol.rs | 389 - crates/owlen-core/src/mcp/remote_client.rs | 1014 - crates/owlen-core/src/mode.rs | 189 - crates/owlen-core/src/model.rs | 209 - crates/owlen-core/src/model/details.rs | 105 - crates/owlen-core/src/oauth.rs | 507 - crates/owlen-core/src/provider/manager.rs | 514 - crates/owlen-core/src/provider/mod.rs | 36 - crates/owlen-core/src/provider/types.rs | 205 - crates/owlen-core/src/providers/mod.rs | 8 - crates/owlen-core/src/providers/ollama.rs | 2825 --- crates/owlen-core/src/router.rs | 157 - crates/owlen-core/src/sandbox.rs | 216 - crates/owlen-core/src/session.rs | 2641 --- crates/owlen-core/src/state/mod.rs | 199 - crates/owlen-core/src/storage.rs | 558 - crates/owlen-core/src/tools.rs | 151 - crates/owlen-core/src/tools/code_exec.rs | 148 - crates/owlen-core/src/tools/fs_tools.rs | 198 - crates/owlen-core/src/tools/registry.rs | 206 - crates/owlen-core/src/tools/web_scrape.rs | 102 - crates/owlen-core/src/tools/web_search.rs | 165 - crates/owlen-core/src/types.rs | 364 - crates/owlen-core/src/ui.rs | 280 - crates/owlen-core/src/usage.rs | 329 - crates/owlen-core/src/validation.rs | 109 - crates/owlen-core/src/wrap_cursor.rs | 90 - crates/owlen-core/tests/agent_tool_flow.rs | 433 - crates/owlen-core/tests/compression.rs | 146 - crates/owlen-core/tests/consent_scope.rs | 100 - crates/owlen-core/tests/file_server.rs | 52 - crates/owlen-core/tests/file_write.rs | 69 - crates/owlen-core/tests/fixtures/README.md | 11 - .../tests/fixtures/ollama_cloud_final.json | 16 - .../fixtures/ollama_cloud_tool_call.json | 20 - .../fixtures/ollama_local_completion.json | 16 - .../tests/fixtures/ollama_tags.json | 28 - crates/owlen-core/tests/long_word_debug.rs | 115 - crates/owlen-core/tests/mcp_timeout.rs | 271 - crates/owlen-core/tests/mode_tool_filter.rs | 110 - crates/owlen-core/tests/ollama_wiremock.rs | 416 - crates/owlen-core/tests/phase9_remoting.rs | 311 - crates/owlen-core/tests/presets.rs | 62 - crates/owlen-core/tests/prompt_server.rs | 76 - .../tests/provider_manager_edge_cases.rs | 517 - crates/owlen-core/tests/web_search_toggle.rs | 141 - crates/owlen-core/tests/wrap_cursor_tests.rs | 96 - crates/owlen-markdown/Cargo.toml | 10 - crates/owlen-markdown/src/lib.rs | 270 - crates/owlen-providers/Cargo.toml | 21 - crates/owlen-providers/src/lib.rs | 3 - crates/owlen-providers/src/ollama/cloud.rs | 136 - crates/owlen-providers/src/ollama/local.rs | 80 - crates/owlen-providers/src/ollama/mod.rs | 7 - crates/owlen-providers/src/ollama/shared.rs | 548 - .../tests/common/mock_provider.rs | 106 - crates/owlen-providers/tests/common/mod.rs | 1 - .../owlen-providers/tests/integration_test.rs | 117 - crates/owlen-tui/Cargo.toml | 55 - crates/owlen-tui/README.md | 12 - crates/owlen-tui/keymap.toml | 210 - crates/owlen-tui/keymap_emacs.toml | 159 - crates/owlen-tui/src/app/generation.rs | 78 - crates/owlen-tui/src/app/handler.rs | 135 - crates/owlen-tui/src/app/messages.rs | 41 - crates/owlen-tui/src/app/mod.rs | 399 - crates/owlen-tui/src/app/mvu.rs | 165 - crates/owlen-tui/src/app/worker.rs | 53 - crates/owlen-tui/src/chat_app.rs | 16018 ---------------- crates/owlen-tui/src/code_app.rs | 47 - crates/owlen-tui/src/color_convert.rs | 57 - crates/owlen-tui/src/commands/mod.rs | 974 - crates/owlen-tui/src/commands/registry.rs | 171 - crates/owlen-tui/src/config.rs | 16 - crates/owlen-tui/src/events.rs | 217 - crates/owlen-tui/src/glass.rs | 254 - crates/owlen-tui/src/highlight.rs | 162 - crates/owlen-tui/src/lib.rs | 35 - crates/owlen-tui/src/model_info_panel.rs | 232 - crates/owlen-tui/src/slash.rs | 248 - crates/owlen-tui/src/state/command_palette.rs | 542 - crates/owlen-tui/src/state/debug_log.rs | 235 - crates/owlen-tui/src/state/file_icons.rs | 320 - crates/owlen-tui/src/state/file_tree.rs | 722 - crates/owlen-tui/src/state/keymap.rs | 850 - crates/owlen-tui/src/state/mod.rs | 34 - crates/owlen-tui/src/state/search.rs | 1057 - crates/owlen-tui/src/state/workspace.rs | 923 - crates/owlen-tui/src/theme_helpers.rs | 212 - crates/owlen-tui/src/theme_util.rs | 96 - crates/owlen-tui/src/toast.rs | 209 - crates/owlen-tui/src/tui_controller.rs | 44 - crates/owlen-tui/src/ui.rs | 7219 ------- crates/owlen-tui/src/widgets/mod.rs | 3 - crates/owlen-tui/src/widgets/model_picker.rs | 896 - crates/owlen-tui/tests/agent_flow_ui.rs | 164 - crates/owlen-tui/tests/chat_snapshots.rs | 322 - crates/owlen-tui/tests/common/mod.rs | 110 - crates/owlen-tui/tests/generation_tests.rs | 216 - .../owlen-tui/tests/guidance_persistence.rs | 84 - crates/owlen-tui/tests/message_tests.rs | 97 - crates/owlen-tui/tests/queue_tests.rs | 116 - ...essibility_modes@high-contrast-100x32.snap | 36 - ...ssibility_modes@reduced-chrome-100x32.snap | 36 - ..._snapshots__chat_idle_snapshot@100x35.snap | 39 - ..._snapshots__chat_idle_snapshot@140x35.snap | 39 - ...t_snapshots__chat_idle_snapshot@80x35.snap | 39 - ..._idle_snapshot_no_anim@no-anim-100x35.snap | 39 - ...pshots__chat_tool_call_snapshot@80x24.snap | 28 - ...napshots__command_palette_focus@80x20.snap | 25 - ...snapshots__emacs_profile@emacs-110x30.snap | 34 - ...hots__guidance_cheatsheet@tab1-100x24.snap | 28 - ...hots__guidance_cheatsheet@tab2-100x24.snap | 28 - ...hots__guidance_onboarding@step1-80x24.snap | 28 - ...ots__guidance_onboarding@step2-100x24.snap | 28 - ..._snapshots__mode_states@command-90x28.snap | 32 - ..._snapshots__mode_states@editing-90x28.snap | 32 - ...t_snapshots__mode_states@normal-90x28.snap | 32 - ...t_snapshots__mode_states@visual-90x28.snap | 32 - crates/owlen-tui/tests/state_tests.rs | 59 - crates/owlen-ui-common/Cargo.toml | 18 - crates/owlen-ui-common/src/color.rs | 206 - crates/owlen-ui-common/src/lib.rs | 14 - crates/owlen-ui-common/src/theme.rs | 1175 -- crates/providers/experimental/README.md | 13 - .../experimental/anthropic/README.md | 5 - .../providers/experimental/gemini/README.md | 5 - .../providers/experimental/openai/README.md | 5 - docs/CHANGELOG_v1.0.md | 180 - docs/adding-providers.md | 62 - docs/architecture.md | 178 - docs/configuration.md | 236 - docs/faq.md | 42 - docs/mcp-reference.md | 62 - docs/migration-guide.md | 215 - docs/migrations/README.md | 13 - docs/phase5-mode-system.md | 214 - docs/platform-support.md | 24 - docs/provider-implementation.md | 98 - docs/repo-map.md | 70 - docs/testing.md | 58 - docs/troubleshooting.md | 143 - docs/tui-mvu-migration.md | 109 - docs/tui-ux-playbook.md | 206 - examples/custom_theme.rs | 28 - examples/mcp_chat.rs | 71 - examples/session_management.rs | 30 - images/chat_view.png | Bin 51204 -> 0 bytes images/help.png | Bin 105220 -> 0 bytes images/layout.png | Bin 65891 -> 0 bytes images/model_select.png | Bin 69158 -> 0 bytes images/select_mode.png | Bin 51995 -> 0 bytes project-analysis.md | 1802 -- samples.json | 1 - scripts/check-windows.sh | 13 - scripts/gen-repo-map.sh | 31 - scripts/release-notes.sh | 57 - themes/README.md | 98 - themes/ansi-basic.toml | 30 - themes/default_dark.toml | 30 - themes/default_light.toml | 30 - themes/dracula.toml | 30 - themes/grayscale-high-contrast.toml | 43 - themes/gruvbox.toml | 30 - themes/material-dark.toml | 30 - themes/material-light.toml | 30 - themes/midnight-ocean.toml | 30 - themes/monokai.toml | 30 - themes/rose-pine.toml | 30 - themes/solarized.toml | 30 - xtask/Cargo.toml | 19 - xtask/src/main.rs | 186 - xtask/src/screenshots.rs | 499 - 246 files changed, 74628 deletions(-) delete mode 100644 .cargo/config.toml delete mode 100644 .github/workflows/macos-check.yml delete mode 100644 .gitignore delete mode 100644 .pre-commit-config.yaml delete mode 100644 .woodpecker.yml delete mode 100644 CHANGELOG.md delete mode 100644 CLAUDE.md delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTRIBUTING.md delete mode 100644 Cargo.toml delete mode 100644 LICENSE delete mode 100644 PKGBUILD delete mode 100644 PROVIDER_MANAGER_OPTIMIZATIONS.md delete mode 100644 README.md delete mode 100644 SECURITY.md delete mode 100644 SQLX_MIGRATION_GUIDE.md delete mode 100644 agents.md delete mode 100644 config.toml delete mode 100644 crates/mcp/client/Cargo.toml delete mode 100644 crates/mcp/client/src/lib.rs delete mode 100644 crates/mcp/code-server/Cargo.toml delete mode 100644 crates/mcp/code-server/src/lib.rs delete mode 100644 crates/mcp/code-server/src/sandbox.rs delete mode 100644 crates/mcp/code-server/src/tools.rs delete mode 100644 crates/mcp/llm-server/Cargo.toml delete mode 100644 crates/mcp/llm-server/src/main.rs delete mode 100644 crates/mcp/prompt-server/Cargo.toml delete mode 100644 crates/mcp/prompt-server/src/lib.rs delete mode 100644 crates/mcp/prompt-server/templates/example.yaml delete mode 100644 crates/mcp/server/Cargo.toml delete mode 100644 crates/mcp/server/src/main.rs delete mode 100644 crates/owlen-cli/Cargo.toml delete mode 100644 crates/owlen-cli/README.md delete mode 100644 crates/owlen-cli/build.rs delete mode 100644 crates/owlen-cli/src/agent_main.rs delete mode 100644 crates/owlen-cli/src/bootstrap.rs delete mode 100644 crates/owlen-cli/src/code_main.rs delete mode 100644 crates/owlen-cli/src/commands/cloud.rs delete mode 100644 crates/owlen-cli/src/commands/mod.rs delete mode 100644 crates/owlen-cli/src/commands/providers.rs delete mode 100644 crates/owlen-cli/src/commands/repo.rs delete mode 100644 crates/owlen-cli/src/commands/security.rs delete mode 100644 crates/owlen-cli/src/commands/tools.rs delete mode 100644 crates/owlen-cli/src/lib.rs delete mode 100644 crates/owlen-cli/src/main.rs delete mode 100644 crates/owlen-cli/src/mcp.rs delete mode 100644 crates/owlen-cli/tests/agent_tests.rs delete mode 100644 crates/owlen-core/Cargo.toml delete mode 100644 crates/owlen-core/README.md delete mode 100644 crates/owlen-core/migrations/0001_create_conversations.sql delete mode 100644 crates/owlen-core/migrations/0002_create_secure_items.sql delete mode 100644 crates/owlen-core/src/agent.rs delete mode 100644 crates/owlen-core/src/agent_registry.rs delete mode 100644 crates/owlen-core/src/automation/mod.rs delete mode 100644 crates/owlen-core/src/automation/repo.rs delete mode 100644 crates/owlen-core/src/config.rs delete mode 100644 crates/owlen-core/src/consent.rs delete mode 100644 crates/owlen-core/src/conversation.rs delete mode 100644 crates/owlen-core/src/credentials.rs delete mode 100644 crates/owlen-core/src/encryption.rs delete mode 100644 crates/owlen-core/src/facade/llm_client.rs delete mode 100644 crates/owlen-core/src/facade/mod.rs delete mode 100644 crates/owlen-core/src/formatting.rs delete mode 100644 crates/owlen-core/src/github.rs delete mode 100644 crates/owlen-core/src/input.rs delete mode 100644 crates/owlen-core/src/lib.rs delete mode 100644 crates/owlen-core/src/llm/mod.rs delete mode 100644 crates/owlen-core/src/mcp.rs delete mode 100644 crates/owlen-core/src/mcp/client.rs delete mode 100644 crates/owlen-core/src/mcp/factory.rs delete mode 100644 crates/owlen-core/src/mcp/failover.rs delete mode 100644 crates/owlen-core/src/mcp/permission.rs delete mode 100644 crates/owlen-core/src/mcp/presets.rs delete mode 100644 crates/owlen-core/src/mcp/protocol.rs delete mode 100644 crates/owlen-core/src/mcp/remote_client.rs delete mode 100644 crates/owlen-core/src/mode.rs delete mode 100644 crates/owlen-core/src/model.rs delete mode 100644 crates/owlen-core/src/model/details.rs delete mode 100644 crates/owlen-core/src/oauth.rs delete mode 100644 crates/owlen-core/src/provider/manager.rs delete mode 100644 crates/owlen-core/src/provider/mod.rs delete mode 100644 crates/owlen-core/src/provider/types.rs delete mode 100644 crates/owlen-core/src/providers/mod.rs delete mode 100644 crates/owlen-core/src/providers/ollama.rs delete mode 100644 crates/owlen-core/src/router.rs delete mode 100644 crates/owlen-core/src/sandbox.rs delete mode 100644 crates/owlen-core/src/session.rs delete mode 100644 crates/owlen-core/src/state/mod.rs delete mode 100644 crates/owlen-core/src/storage.rs delete mode 100644 crates/owlen-core/src/tools.rs delete mode 100644 crates/owlen-core/src/tools/code_exec.rs delete mode 100644 crates/owlen-core/src/tools/fs_tools.rs delete mode 100644 crates/owlen-core/src/tools/registry.rs delete mode 100644 crates/owlen-core/src/tools/web_scrape.rs delete mode 100644 crates/owlen-core/src/tools/web_search.rs delete mode 100644 crates/owlen-core/src/types.rs delete mode 100644 crates/owlen-core/src/ui.rs delete mode 100644 crates/owlen-core/src/usage.rs delete mode 100644 crates/owlen-core/src/validation.rs delete mode 100644 crates/owlen-core/src/wrap_cursor.rs delete mode 100644 crates/owlen-core/tests/agent_tool_flow.rs delete mode 100644 crates/owlen-core/tests/compression.rs delete mode 100644 crates/owlen-core/tests/consent_scope.rs delete mode 100644 crates/owlen-core/tests/file_server.rs delete mode 100644 crates/owlen-core/tests/file_write.rs delete mode 100644 crates/owlen-core/tests/fixtures/README.md delete mode 100644 crates/owlen-core/tests/fixtures/ollama_cloud_final.json delete mode 100644 crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json delete mode 100644 crates/owlen-core/tests/fixtures/ollama_local_completion.json delete mode 100644 crates/owlen-core/tests/fixtures/ollama_tags.json delete mode 100644 crates/owlen-core/tests/long_word_debug.rs delete mode 100644 crates/owlen-core/tests/mcp_timeout.rs delete mode 100644 crates/owlen-core/tests/mode_tool_filter.rs delete mode 100644 crates/owlen-core/tests/ollama_wiremock.rs delete mode 100644 crates/owlen-core/tests/phase9_remoting.rs delete mode 100644 crates/owlen-core/tests/presets.rs delete mode 100644 crates/owlen-core/tests/prompt_server.rs delete mode 100644 crates/owlen-core/tests/provider_manager_edge_cases.rs delete mode 100644 crates/owlen-core/tests/web_search_toggle.rs delete mode 100644 crates/owlen-core/tests/wrap_cursor_tests.rs delete mode 100644 crates/owlen-markdown/Cargo.toml delete mode 100644 crates/owlen-markdown/src/lib.rs delete mode 100644 crates/owlen-providers/Cargo.toml delete mode 100644 crates/owlen-providers/src/lib.rs delete mode 100644 crates/owlen-providers/src/ollama/cloud.rs delete mode 100644 crates/owlen-providers/src/ollama/local.rs delete mode 100644 crates/owlen-providers/src/ollama/mod.rs delete mode 100644 crates/owlen-providers/src/ollama/shared.rs delete mode 100644 crates/owlen-providers/tests/common/mock_provider.rs delete mode 100644 crates/owlen-providers/tests/common/mod.rs delete mode 100644 crates/owlen-providers/tests/integration_test.rs delete mode 100644 crates/owlen-tui/Cargo.toml delete mode 100644 crates/owlen-tui/README.md delete mode 100644 crates/owlen-tui/keymap.toml delete mode 100644 crates/owlen-tui/keymap_emacs.toml delete mode 100644 crates/owlen-tui/src/app/generation.rs delete mode 100644 crates/owlen-tui/src/app/handler.rs delete mode 100644 crates/owlen-tui/src/app/messages.rs delete mode 100644 crates/owlen-tui/src/app/mod.rs delete mode 100644 crates/owlen-tui/src/app/mvu.rs delete mode 100644 crates/owlen-tui/src/app/worker.rs delete mode 100644 crates/owlen-tui/src/chat_app.rs delete mode 100644 crates/owlen-tui/src/code_app.rs delete mode 100644 crates/owlen-tui/src/color_convert.rs delete mode 100644 crates/owlen-tui/src/commands/mod.rs delete mode 100644 crates/owlen-tui/src/commands/registry.rs delete mode 100644 crates/owlen-tui/src/config.rs delete mode 100644 crates/owlen-tui/src/events.rs delete mode 100644 crates/owlen-tui/src/glass.rs delete mode 100644 crates/owlen-tui/src/highlight.rs delete mode 100644 crates/owlen-tui/src/lib.rs delete mode 100644 crates/owlen-tui/src/model_info_panel.rs delete mode 100644 crates/owlen-tui/src/slash.rs delete mode 100644 crates/owlen-tui/src/state/command_palette.rs delete mode 100644 crates/owlen-tui/src/state/debug_log.rs delete mode 100644 crates/owlen-tui/src/state/file_icons.rs delete mode 100644 crates/owlen-tui/src/state/file_tree.rs delete mode 100644 crates/owlen-tui/src/state/keymap.rs delete mode 100644 crates/owlen-tui/src/state/mod.rs delete mode 100644 crates/owlen-tui/src/state/search.rs delete mode 100644 crates/owlen-tui/src/state/workspace.rs delete mode 100644 crates/owlen-tui/src/theme_helpers.rs delete mode 100644 crates/owlen-tui/src/theme_util.rs delete mode 100644 crates/owlen-tui/src/toast.rs delete mode 100644 crates/owlen-tui/src/tui_controller.rs delete mode 100644 crates/owlen-tui/src/ui.rs delete mode 100644 crates/owlen-tui/src/widgets/mod.rs delete mode 100644 crates/owlen-tui/src/widgets/model_picker.rs delete mode 100644 crates/owlen-tui/tests/agent_flow_ui.rs delete mode 100644 crates/owlen-tui/tests/chat_snapshots.rs delete mode 100644 crates/owlen-tui/tests/common/mod.rs delete mode 100644 crates/owlen-tui/tests/generation_tests.rs delete mode 100644 crates/owlen-tui/tests/guidance_persistence.rs delete mode 100644 crates/owlen-tui/tests/message_tests.rs delete mode 100644 crates/owlen-tui/tests/queue_tests.rs delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__accessibility_modes@high-contrast-100x32.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__accessibility_modes@reduced-chrome-100x32.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@100x35.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@140x35.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@80x35.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot_no_anim@no-anim-100x35.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__chat_tool_call_snapshot@80x24.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__command_palette_focus@80x20.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__emacs_profile@emacs-110x30.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab1-100x24.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab2-100x24.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step1-80x24.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step2-100x24.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@command-90x28.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@editing-90x28.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@normal-90x28.snap delete mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@visual-90x28.snap delete mode 100644 crates/owlen-tui/tests/state_tests.rs delete mode 100644 crates/owlen-ui-common/Cargo.toml delete mode 100644 crates/owlen-ui-common/src/color.rs delete mode 100644 crates/owlen-ui-common/src/lib.rs delete mode 100644 crates/owlen-ui-common/src/theme.rs delete mode 100644 crates/providers/experimental/README.md delete mode 100644 crates/providers/experimental/anthropic/README.md delete mode 100644 crates/providers/experimental/gemini/README.md delete mode 100644 crates/providers/experimental/openai/README.md delete mode 100644 docs/CHANGELOG_v1.0.md delete mode 100644 docs/adding-providers.md delete mode 100644 docs/architecture.md delete mode 100644 docs/configuration.md delete mode 100644 docs/faq.md delete mode 100644 docs/mcp-reference.md delete mode 100644 docs/migration-guide.md delete mode 100644 docs/migrations/README.md delete mode 100644 docs/phase5-mode-system.md delete mode 100644 docs/platform-support.md delete mode 100644 docs/provider-implementation.md delete mode 100644 docs/repo-map.md delete mode 100644 docs/testing.md delete mode 100644 docs/troubleshooting.md delete mode 100644 docs/tui-mvu-migration.md delete mode 100644 docs/tui-ux-playbook.md delete mode 100644 examples/custom_theme.rs delete mode 100644 examples/mcp_chat.rs delete mode 100644 examples/session_management.rs delete mode 100644 images/chat_view.png delete mode 100644 images/help.png delete mode 100644 images/layout.png delete mode 100644 images/model_select.png delete mode 100644 images/select_mode.png delete mode 100644 project-analysis.md delete mode 100644 samples.json delete mode 100644 scripts/check-windows.sh delete mode 100755 scripts/gen-repo-map.sh delete mode 100755 scripts/release-notes.sh delete mode 100644 themes/README.md delete mode 100644 themes/ansi-basic.toml delete mode 100644 themes/default_dark.toml delete mode 100644 themes/default_light.toml delete mode 100644 themes/dracula.toml delete mode 100644 themes/grayscale-high-contrast.toml delete mode 100644 themes/gruvbox.toml delete mode 100644 themes/material-dark.toml delete mode 100644 themes/material-light.toml delete mode 100644 themes/midnight-ocean.toml delete mode 100644 themes/monokai.toml delete mode 100644 themes/rose-pine.toml delete mode 100644 themes/solarized.toml delete mode 100644 xtask/Cargo.toml delete mode 100644 xtask/src/main.rs delete mode 100644 xtask/src/screenshots.rs diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 273551b..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,23 +0,0 @@ -[alias] -xtask = "run -p xtask --" - -[target.x86_64-unknown-linux-musl] -linker = "x86_64-linux-gnu-gcc" -rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"] - -[target.aarch64-unknown-linux-gnu] -linker = "aarch64-linux-gnu-gcc" - -[target.aarch64-unknown-linux-musl] -linker = "aarch64-linux-gnu-gcc" -rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"] - -[target.armv7-unknown-linux-gnueabihf] -linker = "arm-linux-gnueabihf-gcc" - -[target.armv7-unknown-linux-musleabihf] -linker = "arm-linux-gnueabihf-gcc" -rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"] - -[target.x86_64-pc-windows-gnu] -linker = "x86_64-w64-mingw32-gcc" diff --git a/.github/workflows/macos-check.yml b/.github/workflows/macos-check.yml deleted file mode 100644 index a606fa3..0000000 --- a/.github/workflows/macos-check.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: macos-check - -on: - push: - branches: - - dev - pull_request: - branches: - - dev - -jobs: - build: - name: cargo check (macOS) - runs-on: macos-latest - steps: - - name: Checkout sources - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo- - - - name: Cargo check - run: cargo check --workspace --all-features - - ollama_regression: - name: ollama provider regression - runs-on: ubuntu-latest - steps: - - name: Checkout sources - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo- - - - name: Run Ollama integration tests - run: cargo test -p owlen-core --test ollama_wiremock - - - name: Run streaming/tool flow tests - run: cargo test -p owlen-core --test agent_tool_flow diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 61e6cad..0000000 --- a/.gitignore +++ /dev/null @@ -1,107 +0,0 @@ -### Rust template -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ -images/generated/ -dev/ -.agents/ -.env -.env.* -!.env.example - -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - -# These are backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ -### JetBrains template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -.idea/ -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 349cfc9..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Pre-commit hooks configuration -# See https://pre-commit.com for more information - -repos: - # General file checks - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - args: ['--allow-multiple-documents'] - - id: check-toml - - id: check-merge-conflict - - id: check-added-large-files - args: ['--maxkb=1000'] - - id: mixed-line-ending - - # Rust formatting - - repo: https://github.com/doublify/pre-commit-rust - rev: v1.0 - hooks: - - id: fmt - name: cargo fmt - description: Format Rust code with rustfmt - - id: cargo-check - name: cargo check - description: Check Rust code compilation - - id: clippy - name: cargo clippy - description: Lint Rust code with clippy - args: ['--all-features', '--', '-D', 'warnings'] - -# Optional: run on all files when config changes -default_install_hook_types: [pre-commit, pre-push] diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index 789932e..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -kind: pipeline -name: pr-checks - -when: - event: - - push - - pull_request - -steps: - - name: fmt-clippy-test - image: rust:1.83 - commands: - - rustup component add rustfmt clippy - - cargo fmt --all -- --check - - cargo clippy --workspace --all-features -- -D warnings - - cargo test --workspace --all-features - ---- -kind: pipeline -name: security-audit - -when: - event: - - push - - cron - branch: - - dev - cron: weekly-security - -steps: - - name: cargo-audit - image: rust:1.83 - commands: - - cargo install cargo-audit --locked - - cargo audit - ---- -kind: pipeline -name: release-tests - -when: - event: tag - tag: v* - -steps: - - name: workspace-tests - image: rust:1.83 - commands: - - rustup component add llvm-tools-preview - - cargo install cargo-llvm-cov --locked - - cargo llvm-cov --workspace --all-features --summary-only - - cargo llvm-cov --workspace --all-features --lcov --output-path coverage.lcov --no-run - ---- -kind: pipeline -name: release - -when: - event: tag - tag: v* - -variables: - - &rust_image 'rust:1.83' - -depends_on: - - release-tests - -matrix: - include: - # Linux - - TARGET: x86_64-unknown-linux-gnu - ARTIFACT: owlen-linux-x86_64-gnu - PLATFORM: linux - EXT: "" - - TARGET: x86_64-unknown-linux-musl - ARTIFACT: owlen-linux-x86_64-musl - PLATFORM: linux - EXT: "" - - TARGET: aarch64-unknown-linux-gnu - ARTIFACT: owlen-linux-aarch64-gnu - PLATFORM: linux - EXT: "" - - TARGET: aarch64-unknown-linux-musl - ARTIFACT: owlen-linux-aarch64-musl - PLATFORM: linux - EXT: "" - - TARGET: armv7-unknown-linux-gnueabihf - ARTIFACT: owlen-linux-armv7-gnu - PLATFORM: linux - EXT: "" - - TARGET: armv7-unknown-linux-musleabihf - ARTIFACT: owlen-linux-armv7-musl - PLATFORM: linux - EXT: "" - # Windows - - TARGET: x86_64-pc-windows-gnu - ARTIFACT: owlen-windows-x86_64 - PLATFORM: windows - EXT: ".exe" - -steps: - - name: build - image: *rust_image - commands: - # Install cross-compilation tools - - apt-get update - - apt-get install -y musl-tools gcc-aarch64-linux-gnu g++-aarch64-linux-gnu gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf mingw-w64 zip - - # Verify cross-compilers are installed - - which aarch64-linux-gnu-gcc || echo "aarch64-linux-gnu-gcc not found!" - - which arm-linux-gnueabihf-gcc || echo "arm-linux-gnueabihf-gcc not found!" - - which x86_64-w64-mingw32-gcc || echo "x86_64-w64-mingw32-gcc not found!" - - # Add rust target - - rustup target add ${TARGET} - - # Set up cross-compilation environment variables and build - - | - case "${TARGET}" in - aarch64-unknown-linux-gnu) - export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/aarch64-linux-gnu-gcc - export CC_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-gcc - export CXX_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-g++ - export AR_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-ar - ;; - aarch64-unknown-linux-musl) - export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/usr/bin/aarch64-linux-gnu-gcc - export CC_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-gcc - export CXX_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-g++ - export AR_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-ar - ;; - armv7-unknown-linux-gnueabihf) - export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc - export CC_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-gcc - export CXX_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-g++ - export AR_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-ar - ;; - armv7-unknown-linux-musleabihf) - export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc - export CC_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-gcc - export CXX_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-g++ - export AR_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-ar - ;; - x86_64-pc-windows-gnu) - export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=/usr/bin/x86_64-w64-mingw32-gcc - export CC_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-gcc - export CXX_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-g++ - export AR_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-ar - ;; - esac - - # Build the project - cargo build --release --all-features --target ${TARGET} - - - name: package - image: *rust_image - commands: - - apt-get update && apt-get install -y zip - - mkdir -p dist - - | - if [ "${PLATFORM}" = "windows" ]; then - cp target/${TARGET}/release/owlen.exe dist/owlen.exe - cp target/${TARGET}/release/owlen-code.exe dist/owlen-code.exe - cd dist - zip -9 ${ARTIFACT}.zip owlen.exe owlen-code.exe - cd .. - mv dist/${ARTIFACT}.zip . - sha256sum ${ARTIFACT}.zip > ${ARTIFACT}.zip.sha256 - else - cp target/${TARGET}/release/owlen dist/owlen - cp target/${TARGET}/release/owlen-code dist/owlen-code - cd dist - tar czf ${ARTIFACT}.tar.gz owlen owlen-code - cd .. - mv dist/${ARTIFACT}.tar.gz . - sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256 - fi - - - name: release-notes - image: *rust_image - commands: - - scripts/release-notes.sh "${CI_COMMIT_TAG}" release-notes.md - - - name: release - image: plugins/gitea-release - settings: - api_key: - from_secret: gitea_token - base_url: https://somegit.dev - files: - - ${ARTIFACT}.tar.gz - - ${ARTIFACT}.tar.gz.sha256 - - ${ARTIFACT}.zip - - ${ARTIFACT}.zip.sha256 - title: Release ${CI_COMMIT_TAG} - note_file: release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 8d0e436..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,144 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- Comprehensive documentation suite including guides for architecture, configuration, testing, and more. -- Emacs keymap profile alongside runtime `:keymap` switching between Vim and Emacs layouts. -- Rustdoc examples for core components like `Provider` and `SessionController`. -- Module-level documentation for `owlen-tui`. -- Provider integration tests (`crates/owlen-providers/tests`) covering registration, routing, and health status handling for the new `ProviderManager`. -- TUI message and generation tests that exercise the non-blocking event loop, background worker, and message dispatch. -- Ollama integration can now talk to Ollama Cloud when an API key is configured. -- Ollama provider will also read `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` environment variables when no key is stored in the config. -- `owlen config doctor`, `owlen config path`, and `owlen upgrade` CLI commands to automate migrations and surface manual update steps. -- Startup provider health check with actionable hints when Ollama or remote MCP servers are unavailable. -- `dev/check-windows.sh` helper script for on-demand Windows cross-checks. -- Global F1 keybinding for the in-app help overlay and a clearer status hint on launch. -- Automatic fallback to the new `ansi_basic` theme when the active terminal only advertises 16-color support. -- Offline provider shim that keeps the TUI usable while primary providers are unreachable and communicates recovery steps inline. -- `owlen cloud` subcommands (`setup`, `status`, `models`, `logout`) for managing Ollama Cloud credentials without hand-editing config files. -- Tabbed model selector that separates local and cloud providers, including cloud indicators in the UI. -- Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys). -- Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`. -- Input panel respects a new `ui.input_max_rows` setting so long prompts expand predictably before scrolling kicks in. -- Adaptive TUI layout with responsive 80/120-column breakpoints, refreshed glass/neon theming, and animated focus rings for pane transitions. -- Configurable `ui.layers` and `ui.animations` settings to tune glass elevation, neon intensity, and opt-in micro-animations. -- Adaptive transcript compactor with configurable auto mode, CLI opt-out (`--no-auto-compress`), and `:compress` commands for manual runs and toggling. -- Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching. -- Inline guidance overlay adds a three-step onboarding tour, keymap-aware cheat sheets (F1 / `?`), and persists completion state via `ui.guidance`. -- Status surface renders a layered HUD with streaming/tool indicators, contextual gauges, and redesigned toast cards featuring icons, countdown timers, and a compact history log. -- Published a TUI UX & keybinding playbook documenting modal ergonomics, command metadata, theming tokens, and animation policy. -- Automated TUI regression snapshots now cover mode transitions, keymap variants, accessibility presets, and multiple terminal breakpoints. -- Cloud usage tracker persists hourly/weekly token totals, adds a `:limits` command, shows live header badges, and raises toast warnings at 80 %/95 % of the configured quotas. -- Message rendering caches wrapped lines and throttles streaming redraws to keep the TUI responsive on long sessions. -- Model picker badges now inspect provider capabilities so vision/audio/thinking models surface the correct icons even when descriptions are sparse. -- Chat history honors `ui.scrollback_lines`, trimming older rows to keep the TUI responsive and surfacing a "↓ New messages" badge whenever updates land off-screen. - -### Changed -- The main `README.md` has been updated to be more concise and link to the new documentation. -- Default configuration now pre-populates both `providers.ollama` and `providers.ollama-cloud` entries so switching between local and cloud backends is a single setting change. -- `McpMode` support was restored with explicit validation; `remote_only`, `remote_preferred`, and `local_only` now behave predictably. -- Configuration loading performs structural validation and fails fast on missing default providers or invalid MCP definitions. -- Ollama provider error handling now distinguishes timeouts, missing models, and authentication failures. -- The `web_search` tool now proxies through Ollama Cloud’s `/api/web_search` endpoint and is hidden whenever the active provider cannot reach the cloud. The legacy `web.search` alias stays enabled for older sessions. -- `owlen` warns when the active terminal likely lacks 256-color support. -- `config.toml` now carries a schema version (`1.2.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures. -- Model selector navigation (Tab/Shift-Tab) now switches between local and cloud tabs while preserving selection state. -- Header displays the active model together with its provider (e.g., `Model (Provider)`), improving clarity when swapping backends. -- Documentation refreshed to cover the message handler architecture, the background health worker, multi-provider configuration, and the new provider onboarding checklist. - ---- - -## [0.2.0] - 2025-10-24 - -### Added -- Cloud usage tracker now persists hourly and weekly token totals, exposes a `:limits` command, and renders live gradient gauges in the header with 80 %/95 % toast notifications. -- Web search tooling is available whenever Ollama Cloud is configured, giving the assistant automatic access to the `web_search` function with runtime toggles via `:web on|off`. Legacy dotted references continue to resolve through the alias layer. -- Provider registry aggregates local and cloud Ollama models, including health checks, scope badges, and graceful fallback between providers. -- Release documentation covers the migration from v0.1, including the new config schema defaults, cloud setup guide, and troubleshooting steps for common errors. - -### Changed -- Workspace packages, distribution metadata, and README badges now report version `0.2.0`. -- Chat header adopts a cockpit layout powered by Ratatui 0.29 flex layouts and Tailwind-inspired gradients, clearly surfacing context and quota usage. -- Cloud requests now default to the canonical `https://ollama.com` endpoint and automatically attach the `Authorization: Bearer ` header resolved from config or environment variables. -- Configuration templates enable both local (`providers.ollama`) and cloud (`providers.ollama_cloud`) entries by default, complete with TTLs, context windows, and quota placeholders. - -### Fixed -- Selecting Ollama Cloud without a valid API key now surfaces actionable unauthorized toasts and falls back to the last working local provider instead of looping 401 responses. -- Rate-limited cloud responses raise non-fatal warnings so sessions remain usable while the usage tracker records the incident. - ---- - -## [0.1.11] - 2025-10-18 - -### Changed -- Bump workspace packages and distribution metadata to version `0.1.11`. - -## [0.1.10] - 2025-10-03 - -### Added -- **Material Light Theme**: A new built-in theme, `material-light`, has been added. - -### Fixed -- **UI Readability**: Fixed a bug causing unreadable text in light themes. -- **Visual Selection**: The visual selection mode now correctly colors unselected text portions. - -### Changed -- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected. -- **In-App Help**: The `:help` menu has been significantly expanded and updated. - -## [0.1.9] - 2025-10-03 - -*This version corresponds to the release tagged v0.1.10 in the source repository.* - -### Added -- **Material Light Theme**: A new built-in theme, `material-light`, has been added. - -### Fixed -- **UI Readability**: Fixed a bug causing unreadable text in light themes. -- **Visual Selection**: The visual selection mode now correctly colors unselected text portions. - -### Changed -- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected. -- **In-App Help**: The `:help` menu has been significantly expanded and updated. - -## [0.1.8] - 2025-10-02 - -### Added -- **Command Autocompletion**: Implemented intelligent command suggestions and Tab completion in command mode. - -### Changed -- **Build & CI**: Fixed cross-compilation for ARM64, ARMv7, and Windows. - -## [0.1.7] - 2025-10-02 - -### Added -- **Tabbed Help System**: The help menu is now organized into five tabs for easier navigation. -- **Command Aliases**: Added `:o` as a short alias for `:load` / `:open`. - -### Changed -- **Session Management**: Improved AI-generated session descriptions. - -## [0.1.6] - 2025-10-02 - -### Added -- **Platform-Specific Storage**: Sessions are now saved to platform-appropriate directories (e.g., `~/.local/share/owlen` on Linux). -- **AI-Generated Session Descriptions**: Conversations can be automatically summarized on save. - -### Changed -- **Migration**: Users on older versions can manually move their sessions from `~/.config/owlen/sessions` to the new platform-specific directory. - -## [0.1.4] - 2025-10-01 - -### Added -- **Multi-Platform Builds**: Pre-built binaries are now provided for Linux (x86_64, aarch64, armv7) and Windows (x86_64). -- **AUR Package**: Owlen is now available on the Arch User Repository. - -### Changed -- **Build System**: Switched from OpenSSL to rustls for better cross-platform compatibility. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 2b0c2f2..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,309 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -OWLEN is a Rust-powered, terminal-first interface for interacting with local and cloud language models. It uses a multi-provider architecture with vim-style navigation and session management. - -**Status**: Alpha (v0.2.0) - core features functional but expect occasional bugs and breaking changes. - -## Build, Test & Development Commands - -### Building -```bash -# Build all crates -cargo build - -# Build release binary -cargo build --release - -# Run the TUI (requires Ollama running) -./target/release/owlen -# or -cargo run -p owlen-cli - -# Build for specific target (cross-compilation) -dev/local_build.sh x86_64-unknown-linux-gnu -``` - -### Testing -```bash -# Run all tests -cargo test --all - -# Test specific crate -cargo test -p owlen-core -cargo test -p owlen-tui -cargo test -p owlen-providers - -# Linting and formatting -cargo clippy --all -- -D warnings -cargo fmt --all -- --check - -# Pre-commit hooks (install once with `pre-commit install`) -pre-commit run --all-files -``` - -### Developer Tasks -```bash -# Regenerate screenshots for documentation -cargo xtask screenshots -cargo xtask screenshots --no-png # skip PNG generation -cargo xtask screenshots --output images/ - -# Regenerate repository map after structural changes -scripts/gen-repo-map.sh - -# Platform compatibility checks -scripts/check-windows.sh # Windows GNU toolchain smoke test -``` - -### Running Individual Tests -```bash -# Run a specific test by name -cargo test test_name - -# Run tests with output -cargo test -- --nocapture - -# Run tests in a specific file -cargo test --test integration_test_name -``` - -## Architecture & Key Concepts - -### Workspace Structure (Cargo workspace with 13+ crates) -- **owlen-core**: Core abstractions, provider traits, session management, MCP client layer (UI-agnostic) -- **owlen-tui**: Terminal UI built with ratatui (event loop, rendering, vim modes) -- **owlen-cli**: Entry point that parses args, loads config, launches TUI or headless flows -- **owlen-providers**: Concrete provider adapters (Ollama local, Ollama Cloud) -- **owlen-markdown**: Markdown parsing and rendering -- **crates/mcp/**: Model Context Protocol infrastructure - - **llm-server**: Wraps owlen-providers behind MCP boundary (generate_text tools) - - **server**: Generic MCP server for file ops and workspace tools - - **client**: MCP client implementation - - **code-server**: Code execution sandboxing - - **prompt-server**: Template rendering -- **xtask**: Development automation tasks (screenshots, etc.) - -### Dependency Boundaries -- **owlen-core is the dependency ceiling**: Must stay free of terminal logic, CLIs, or provider HTTP clients -- **owlen-cli only orchestrates startup/shutdown**: Business logic belongs in owlen-core or library crates -- **owlen-mcp-llm-server is the only crate that directly talks to providers**: UI/CLI communicate through MCP clients - -### Multi-Provider Architecture -``` -[owlen-tui / owlen-cli] - │ - │ chat + model requests - ▼ -[owlen-core::ProviderManager] ──> Arc - │ ▲ - │ │ implements ModelProvider - ▼ │ -[owlen-core::mcp::RemoteMcpClient] ────────┘ - │ (JSON-RPC over stdio) - ▼ -┌────────────────────────────────────────────────┐ -│ MCP Process Boundary (spawned per provider) │ -│ │ -│ crates/mcp/llm-server ──> owlen-providers::* │ -└────────────────────────────────────────────────┘ -``` - -Key points: -- **ProviderManager** tracks health, merges model catalogs, and dispatches requests -- **RemoteMcpClient** bridges MCP protocol to ModelProvider trait -- **MCP servers** isolate provider-specific code in separate processes -- **Health & availability** tracked via background workers and surfaced in TUI picker - -### Event Flow & TUI Architecture -1. User input → Event loop → Message handler → Session controller → Provider manager → Provider -2. Non-blocking design: TUI remains responsive during streaming (see `agents.md` for planned improvements) -3. Modal workflow: Normal, Insert, Visual, Command modes (vim-inspired) -4. AppMessage stream carries async events (provider responses, health checks) - -### Session & Conversation Management -- **Conversation** (owlen-core): Holds messages and metadata -- **SessionController**: High-level orchestrator managing history, context, model switching -- Conversations stored in platform-specific data directory (can be encrypted with AES-GCM) - -### Configuration -Platform-specific locations: -- Linux: `~/.config/owlen/config.toml` -- macOS: `~/Library/Application Support/owlen/config.toml` -- Windows: `%APPDATA%\owlen\config.toml` - -Commands: -```bash -owlen config init # Create default config -owlen config init --force # Overwrite existing -owlen config path # Print config location -owlen config doctor # Migrate legacy configs -``` - -## Coding Conventions - -### Commit Messages -Follow [Conventional Commits](https://www.conventionalcommits.org/): -``` -[optional scope]: - -[optional body] - -[optional footer(s)] -``` - -Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci` - -Example: `feat(provider): add support for Gemini Pro` - -### Pre-commit Hooks -Hooks automatically run on commit (install with `pre-commit install`): -- `cargo fmt` -- `cargo check` -- `cargo clippy --all-features` -- File hygiene (trailing whitespace, EOF newlines) - -To bypass (not recommended): `git commit --no-verify` - -### Style Guidelines -- Run `cargo fmt` before committing -- Address all `cargo clippy` warnings -- Use `#[cfg(test)]` modules for unit tests in same file -- Place integration tests in `tests/` directory - -## Provider Development - -### Adding a New Provider -Follow `docs/adding-providers.md`: -1. Implement `ModelProvider` trait in `owlen-providers` -2. Set `ProviderMetadata::provider_type` (Local/Cloud) -3. Register with `ProviderManager` in startup code -4. Optionally expose through MCP server -5. Add integration tests following `crates/owlen-providers/tests` pattern -6. Document config in `docs/configuration.md` and default `config.toml` -7. Update `README.md`, `CHANGELOG.md`, `docs/troubleshooting.md` - -See `docs/provider-implementation.md` for trait-level details. - -### MCP Tool Naming -Enforce spec-compliant identifiers: `^[A-Za-z0-9_-]{1,64}$` -- Use underscores or hyphens (e.g., `web_search`, `filesystem_read`) -- Avoid dotted names (legacy incompatible) -- Qualify with `{server}__{tool}` when multiple servers overlap (e.g., `filesystem__read`) - -## Repository Automation - -OWLEN includes Git-aware automation for code review and commit templating: - -### CLI Commands -```bash -# Generate commit message from staged diff -owlen repo commit-template -owlen repo commit-template --working-tree # inspect unstaged - -# Review branch or PR -owlen repo review -owlen repo review --owner Owlibou --repo owlen --number 42 --token-env GITHUB_TOKEN -``` - -### TUI Commands -``` -:repo template # inject commit template into chat -:repo review [--base BRANCH] [--head REF] # review local changes -``` - -## Key Files & Entry Points - -### Main Entry Points -- `crates/owlen-cli/src/main.rs` - CLI entry point (argument parsing, config loading) -- `crates/owlen-tui/src/app/mod.rs` - Main TUI application and event dispatch -- `crates/owlen-core/src/provider.rs` - ModelProvider trait definition - -### Configuration & State -- `crates/owlen-core/src/config.rs` - Configuration loading and parsing -- `crates/owlen-core/src/session.rs` - Session and conversation management -- `crates/owlen-core/src/storage.rs` - Persistence layer - -### Provider Infrastructure -- `crates/owlen-providers/src/ollama/` - Ollama local and cloud providers -- `crates/mcp/llm-server/src/main.rs` - MCP LLM server process -- `crates/owlen-core/src/mcp/remote_client.rs` - MCP client implementation - -## Testing Strategy - -### Unit Tests -Place in `#[cfg(test)]` modules within source files for isolated component testing. - -### Integration Tests -Place in `tests/` directories: -- `crates/owlen-providers/tests/` - Provider integration tests -- Test registration, model aggregation, request routing, health transitions - -### Focus Areas -- Command palette state machine -- Agent response parsing -- MCP protocol abstractions -- Provider manager health cache -- Session controller lifecycle - -## Documentation Structure - -- `README.md` - User-facing overview, installation, features -- `CONTRIBUTING.md` - Contribution guidelines, development setup -- `docs/architecture.md` - High-level architecture (read first!) -- `docs/repo-map.md` - Workspace layout snapshot -- `docs/adding-providers.md` - Provider implementation checklist -- `docs/provider-implementation.md` - Trait-level provider details -- `docs/testing.md` - Testing guide -- `docs/troubleshooting.md` - Common issues and solutions -- `docs/configuration.md` - Configuration reference -- `docs/platform-support.md` - OS support matrix - -## Important Implementation Notes - -### When Working on TUI Code -- Modal state machine is critical: Normal ↔ Insert ↔ Visual ↔ Command -- Status line shows current mode (use as regression check) -- Non-blocking event loop planned (see `agents.md`) -- Command palette state lives in `owlen_tui::state` -- Follow Model-View-Update pattern for new features - -### When Working on Providers -- Never import providers directly in owlen-tui or owlen-cli -- All provider communication goes through owlen-core abstractions -- Health checks run on background workers -- Model discovery fans out through ProviderManager - -### When Working on MCP Integration -- RemoteMcpClient implements both MCP client traits and ModelProvider -- MCP servers are short-lived, narrowly scoped binaries -- Tool calls travel same transport as chat requests -- Consent prompts surface in UI via session events - -## Platform Support - -- **Primary**: Linux (Arch AUR: `owlen-git`) -- **Supported**: macOS 12+ (requires Command Line Tools for OpenSSL) -- **Experimental**: Windows (GNU toolchain, some Docker features disabled) - -Cross-platform testing: Use `dev/local_build.sh` and `scripts/check-windows.sh` - -## Dependencies & Async Runtime - -- **Async runtime**: tokio with "full" features -- **TUI framework**: ratatui 0.29 with palette features -- **HTTP client**: reqwest with rustls-tls (no native-tls) -- **Database**: SQLx with sqlite, tokio runtime -- **Serialization**: serde + serde_json -- **Testing**: tokio-test for async test utilities - -## Security & Privacy - -- Local-first: LLM calls route through local Ollama by default -- Session encryption: Set `privacy.encrypt_local_data = true` for AES-GCM storage -- No telemetry sent -- Outbound requests only when explicitly enabling remote tools/providers -- Config migrations carry schema version and warn on deprecated keys diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 96d1734..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,121 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that are welcoming, open, and respectful. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[security@owlibou.com](mailto:security@owlibou.com). All complaints will be -reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interaction in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. - -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index fe4884d..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,126 +0,0 @@ -# Contributing to Owlen - -First off, thank you for considering contributing to Owlen! It's people like you that make Owlen such a great tool. - -Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. - -## Code of Conduct - -This project and everyone participating in it is governed by the [Owlen Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior. - -## How Can I Contribute? - -### Repository map - -Need a quick orientation before diving in? Start with the curated [repo map](docs/repo-map.md) for a two-level directory overview. If you move folders around, regenerate it with `scripts/gen-repo-map.sh`. - -### Reporting Bugs - -This is one of the most helpful ways you can contribute. Before creating a bug report, please check a few things: - -1. **Check the [troubleshooting guide](docs/troubleshooting.md).** Your issue might be a common one with a known solution. -2. **Search the existing issues.** It's possible someone has already reported the same bug. If so, add a comment to the existing issue instead of creating a new one. - -When you are creating a bug report, please include as many details as possible. Fill out the required template, the information it asks for helps us resolve issues faster. - -### Suggesting Enhancements - -If you have an idea for a new feature or an improvement to an existing one, we'd love to hear about it. Please provide as much context as you can about what you're trying to achieve. - -### Your First Code Contribution - -Unsure where to begin contributing to Owlen? You can start by looking through `good first issue` and `help wanted` issues. - -### Pull Requests - -The process for submitting a pull request is as follows: - -1. **Fork the repository** and create your branch from `main`. -2. **Set up pre-commit hooks** (see [Development Setup](#development-setup) above). This will automatically format and lint your code. -3. **Make your changes.** -4. **Run the tests.** - - `cargo test --all` -5. **Commit your changes.** The pre-commit hooks will automatically run `cargo fmt`, `cargo check`, and `cargo clippy`. If you need to bypass the hooks (not recommended), use `git commit --no-verify`. -6. **Add a clear, concise commit message.** We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. -7. **Push to your fork** and submit a pull request to Owlen's `main` branch. -8. **Include a clear description** of the problem and solution. Include the relevant issue number if applicable. -9. **Declare AI assistance.** If any part of the patch was generated with an AI tool (e.g., ChatGPT, Claude Code), call that out in the PR description. A human maintainer must review and approve AI-assisted changes before merge. - -## Development Setup - -To get started with the codebase, you'll need to have Rust installed. Then, you can clone the repository and build the project: - -```sh -git clone https://github.com/Owlibou/owlen.git -cd owlen -cargo build -``` - -### Pre-commit Hooks - -We use [pre-commit](https://pre-commit.com/) to automatically run formatting and linting checks before each commit. This helps maintain code quality and consistency. - -**Install pre-commit:** - -```sh -# Arch Linux -sudo pacman -S pre-commit - -# Other Linux/macOS -pip install pre-commit - -# Verify installation -pre-commit --version -``` - -**Setup the hooks:** - -```sh -cd owlen -pre-commit install -``` - -Once installed, the hooks will automatically run on every commit. You can also run them manually: - -```sh -# Run on all files -pre-commit run --all-files - -# Run on staged files only -pre-commit run -``` - -The pre-commit hooks will check: -- Code formatting (`cargo fmt`) -- Compilation (`cargo check`) -- Linting (`cargo clippy --all-features`) -- General file hygiene (trailing whitespace, EOF newlines, etc.) - -## Coding Style - -- We use `cargo fmt` for automated code formatting. Please run it before committing your changes. -- We use `cargo clippy` for linting. Your code should be free of any clippy warnings. - -## Commit Message Conventions - -We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for our commit messages. This allows for automated changelog generation and makes the project history easier to read. - -The basic format is: - -``` -[optional scope]: - -[optional body] - -[optional footer(s)] -``` - -**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`. - -**Example:** - -``` -feat(provider): add support for Gemini Pro -``` - -Thank you for your contribution! diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 789e20d..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,90 +0,0 @@ -[workspace] -resolver = "2" -members = [ - "crates/owlen-core", - "crates/owlen-ui-common", - "crates/owlen-tui", - "crates/owlen-cli", - "crates/owlen-providers", - "crates/mcp/server", - "crates/mcp/llm-server", - "crates/mcp/client", - "crates/mcp/code-server", - "crates/mcp/prompt-server", - "crates/owlen-markdown", - "xtask", -] -exclude = [] - -[workspace.package] -version = "0.2.0" -edition = "2024" -authors = ["Owlibou"] -license = "AGPL-3.0" -repository = "https://somegit.dev/Owlibou/owlen" -homepage = "https://somegit.dev/Owlibou/owlen" -keywords = ["llm", "tui", "cli", "ollama", "chat"] -categories = ["command-line-utilities"] - -[workspace.dependencies] -# Async runtime and utilities -tokio = { version = "1.0", features = ["full"] } -tokio-stream = "0.1" -tokio-util = { version = "0.7", features = ["rt"] } -futures = "0.3" -futures-util = "0.3" - -# TUI framework -ratatui = { version = "0.29", features = ["palette"] } -crossterm = "0.28.1" -tui-textarea = "0.7" - -# HTTP client and JSON handling -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0" } - -# Utilities -uuid = { version = "1.0", features = ["v4", "serde"] } -anyhow = "1.0" -thiserror = "2.0" -nix = "0.29" -which = "6.0" -tempfile = "3.8" -jsonschema = "0.17" -aes-gcm = "0.10" -ring = ">=0.17.12" # Security fix for CVE in 0.17.9 (AES panic vulnerability) -keyring = "3.0" -chrono = { version = "0.4", features = ["serde"] } -urlencoding = "2.1" -regex = "1.10" -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "sqlite", "macros", "uuid", "chrono", "migrate"] } -log = "0.4" -dirs = "5.0" -serde_yaml = "0.9" -handlebars = "6.0" -once_cell = "1.19" -base64 = "0.22" -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } -mime_guess = "2.0" - -# Configuration -toml = "0.8" -shellexpand = "3.1" - -# Database -sled = "0.34" - -# For better text handling -textwrap = "0.16" - -# Async traits -async-trait = "0.1" - -# CLI framework -clap = { version = "4.0", features = ["derive"] } - -# Dev dependencies -tokio-test = "0.4" - -# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/LICENSE b/LICENSE deleted file mode 100644 index be3f7b2..0000000 --- a/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/PKGBUILD b/PKGBUILD deleted file mode 100644 index 7dc6ee1..0000000 --- a/PKGBUILD +++ /dev/null @@ -1,49 +0,0 @@ -# Maintainer: vikingowl -pkgname=owlen -pkgver=0.2.0 -pkgrel=1 -pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features" -arch=('x86_64') -url="https://somegit.dev/Owlibou/owlen" -license=('AGPL-3.0-or-later') -depends=('gcc-libs') -makedepends=('cargo' 'git') -options=(!lto) # avoid LTO-linked ring symbol drop with lld -source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz") -sha256sums=('cabb1cfdfc247b5d008c6c5f94e13548bcefeba874aae9a9d45aa95ae1c085ec') - -prepare() { - cd $pkgname - cargo fetch --target "$(rustc -vV | sed -n 's/host: //p')" -} - -build() { - cd $pkgname - export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed" - export CARGO_PROFILE_RELEASE_LTO=false - export CARGO_TARGET_DIR=target - cargo build --frozen --release --all-features -} - -check() { - cd $pkgname - export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed" - cargo test --frozen --all-features -} - -package() { - cd $pkgname - - # Install binaries - install -Dm755 target/release/owlen "$pkgdir/usr/bin/owlen" - install -Dm755 target/release/owlen-code "$pkgdir/usr/bin/owlen-code" - - # Install documentation - install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" - - # Install built-in themes for reference - install -Dm644 themes/README.md "$pkgdir/usr/share/$pkgname/themes/README.md" - for theme in themes/*.toml; do - install -Dm644 "$theme" "$pkgdir/usr/share/$pkgname/themes/$(basename $theme)" - done -} diff --git a/PROVIDER_MANAGER_OPTIMIZATIONS.md b/PROVIDER_MANAGER_OPTIMIZATIONS.md deleted file mode 100644 index 976ffe8..0000000 --- a/PROVIDER_MANAGER_OPTIMIZATIONS.md +++ /dev/null @@ -1,400 +0,0 @@ -# ProviderManager Clone Overhead Optimizations - -## Summary - -This document describes the optimizations applied to `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/provider/manager.rs` to reduce clone overhead as identified in the project analysis report. - -## Problems Identified - -1. **Lines 94-100** (`list_all_models`): Clones all provider Arc handles and IDs unnecessarily into an intermediate Vec -2. **Lines 162-168** (`refresh_health`): Collects into Vec with unnecessary clones before spawning async tasks -3. **Line 220** (`provider_statuses()`): Clones entire HashMap on every call - -The report estimated that 15-20% of `list_all_models` time was spent on String clones alone. - -## Optimizations Applied - -### 1. Change `status_cache` to Arc-Wrapped HashMap - -**File**: `crates/owlen-core/src/provider/manager.rs` - -**Line 28**: Change struct definition -```rust -// Before: -status_cache: RwLock>, - -// After: -status_cache: RwLock>>, -``` - -**Rationale**: Using `Arc` allows cheap cloning via reference counting instead of deep-copying the entire HashMap. - -### 2. Update Constructor (`new`) - -**Lines 41-44**: -```rust -// Before: -Self { - providers: RwLock::new(HashMap::new()), - status_cache: RwLock::new(status_cache), -} - -// After: -Self { - providers: RwLock::new(HashMap::new()), - status_cache: RwLock::new(Arc::new(status_cache)), -} -``` - -### 3. Update Default Implementation - -**Lines 476-479**: -```rust -// Before: -Self { - providers: RwLock::new(HashMap::new()), - status_cache: RwLock::new(HashMap::new()), -} - -// After: -Self { - providers: RwLock::new(HashMap::new()), - status_cache: RwLock::new(Arc::new(HashMap::new())), -} -``` - -### 4. Update `register_provider` (Copy-on-Write Pattern) - -**Lines 56-59**: -```rust -// Before: -self.status_cache - .write() - .await - .insert(provider_id, ProviderStatus::Unavailable); - -// After: -// Update status cache with copy-on-write -let mut guard = self.status_cache.write().await; -let mut new_cache = (**guard).clone(); -new_cache.insert(provider_id, ProviderStatus::Unavailable); -*guard = Arc::new(new_cache); -``` - -**Rationale**: When updating the HashMap, we clone the inner HashMap (not the Arc), modify it, then wrap in a new Arc. This keeps the immutability contract while allowing readers to continue using old snapshots. - -### 5. Update `generate` Method (Two Locations) - -**Lines 76-79** (Available status): -```rust -// Before: -self.status_cache - .write() - .await - .insert(provider_id.to_string(), ProviderStatus::Available); - -// After: -// Update status cache with copy-on-write -let mut guard = self.status_cache.write().await; -let mut new_cache = (**guard).clone(); -new_cache.insert(provider_id.to_string(), ProviderStatus::Available); -*guard = Arc::new(new_cache); -``` - -**Lines 83-86** (Unavailable status): -```rust -// Before: -self.status_cache - .write() - .await - .insert(provider_id.to_string(), ProviderStatus::Unavailable); - -// After: -// Update status cache with copy-on-write -let mut guard = self.status_cache.write().await; -let mut new_cache = (**guard).clone(); -new_cache.insert(provider_id.to_string(), ProviderStatus::Unavailable); -*guard = Arc::new(new_cache); -``` - -### 6. Update `list_all_models` (Avoid Intermediate Vec) - -**Lines 94-132**: -```rust -// Before: -let providers: Vec<(String, Arc)> = { - let guard = self.providers.read().await; - guard - .iter() - .map(|(id, provider)| (id.clone(), Arc::clone(provider))) - .collect() -}; - -let mut tasks = FuturesUnordered::new(); - -for (provider_id, provider) in providers { - tasks.push(async move { - let log_id = provider_id.clone(); - // ... - }); -} - -// After: -let mut tasks = FuturesUnordered::new(); - -{ - let guard = self.providers.read().await; - for (provider_id, provider) in guard.iter() { - // Clone Arc and String, but keep lock held for shorter duration - let provider_id = provider_id.clone(); - let provider = Arc::clone(provider); - - tasks.push(async move { - // No need for log_id clone - just use provider_id directly - // ... - }); - } -} -``` - -**Rationale**: -- Eliminates intermediate Vec allocation -- Still clones provider_id and Arc, but does so inline during iteration -- Lock is held only during spawning (which is fast), not during actual health checks -- Removes unnecessary `log_id` clone inside async block - -### 7. Update `list_all_models` Status Updates (Copy-on-Write) - -**Lines 149-153**: -```rust -// Before: -{ - let mut guard = self.status_cache.write().await; - for (provider_id, status) in status_updates { - guard.insert(provider_id, status); - } -} - -// After: -{ - let mut guard = self.status_cache.write().await; - let mut new_cache = (**guard).clone(); - for (provider_id, status) in status_updates { - new_cache.insert(provider_id, status); - } - *guard = Arc::new(new_cache); -} -``` - -### 8. Update `refresh_health` (Avoid Intermediate Vec) - -**Lines 162-184**: -```rust -// Before: -let providers: Vec<(String, Arc)> = { - let guard = self.providers.read().await; - guard - .iter() - .map(|(id, provider)| (id.clone(), Arc::clone(provider))) - .collect() -}; - -let mut tasks = FuturesUnordered::new(); -for (provider_id, provider) in providers { - tasks.push(async move { - // ... - }); -} - -// After: -let mut tasks = FuturesUnordered::new(); - -{ - let guard = self.providers.read().await; - for (provider_id, provider) in guard.iter() { - let provider_id = provider_id.clone(); - let provider = Arc::clone(provider); - - tasks.push(async move { - // ... - }); - } -} -``` - -### 9. Update `refresh_health` Status Updates (Copy-on-Write) - -**Lines 191-194**: -```rust -// Before: -{ - let mut guard = self.status_cache.write().await; - for (provider_id, status) in &updates { - guard.insert(provider_id.clone(), *status); - } -} - -// After: -{ - let mut guard = self.status_cache.write().await; - let mut new_cache = (**guard).clone(); - for (provider_id, status) in &updates { - new_cache.insert(provider_id.clone(), *status); - } - *guard = Arc::new(new_cache); -} -``` - -### 10. Update `provider_statuses()` Return Type - -**Lines 218-221**: -```rust -// Before: -pub async fn provider_statuses(&self) -> HashMap { - let guard = self.status_cache.read().await; - guard.clone() -} - -// After: -/// Snapshot the currently cached statuses. -/// Returns an Arc to avoid cloning the entire HashMap on every call. -pub async fn provider_statuses(&self) -> Arc> { - let guard = self.status_cache.read().await; - Arc::clone(&guard) -} -``` - -**Rationale**: Returns Arc for cheap reference-counted sharing instead of deep clone. - -## Call Site Updates - -### File: `crates/owlen-cli/src/commands/providers.rs` - -**Lines 218-220**: -```rust -// Before: -let statuses = manager.provider_statuses().await; -print_models(records, models, statuses); - -// After: -let statuses = manager.provider_statuses().await; -print_models(records, models, (*statuses).clone()); -``` - -**Rationale**: `print_models` expects owned HashMap. Clone once at call site instead of always cloning in `provider_statuses()`. - -### File: `crates/owlen-tui/src/app/worker.rs` - -**Add import**: -```rust -use std::collections::HashMap; -``` - -**Lines 20-52**: -```rust -// Before: -let mut last_statuses = provider_manager.provider_statuses().await; - -loop { - // ... - let statuses = provider_manager.refresh_health().await; - - for (provider_id, status) in statuses { - let changed = match last_statuses.get(&provider_id) { - Some(previous) => previous != &status, - None => true, - }; - - last_statuses.insert(provider_id.clone(), status); - - if changed && message_tx.send(/* ... */).is_err() { - return; - } - } -} - -// After: -let mut last_statuses: Arc> = - provider_manager.provider_statuses().await; - -loop { - // ... - let statuses = provider_manager.refresh_health().await; - - for (provider_id, status) in &statuses { - let changed = match last_statuses.get(provider_id) { - Some(previous) => previous != status, - None => true, - }; - - if changed && message_tx.send(AppMessage::ProviderStatus { - provider_id: provider_id.clone(), - status: *status, - }).is_err() { - return; - } - } - - // Update last_statuses after processing all changes - last_statuses = Arc::new(statuses); -} -``` - -**Rationale**: -- Store Arc instead of owned HashMap -- Iterate over references in loop (avoid moving statuses HashMap) -- Replace entire Arc after all changes processed -- Only clone provider_id when sending message - -## Performance Impact - -**Expected improvements**: -- **`list_all_models`**: 15-20% reduction in execution time (eliminates String clone overhead) -- **`refresh_health`**: Similar benefits, plus avoids intermediate Vec allocation -- **`provider_statuses`**: ~100x faster for typical HashMap sizes (Arc clone vs deep clone) -- **Background worker**: Reduced allocations in hot loop (30-second interval) - -**Trade-offs**: -- Status updates now require cloning the HashMap (copy-on-write) -- However, status updates are infrequent compared to reads -- Overall: Optimizes the hot path (reads) at the expense of the cold path (writes) - -## Testing - -Run the following to verify correctness: -```bash -cargo test -p owlen-core provider -cargo test -p owlen-tui -cargo test -p owlen-cli -``` - -All existing tests should pass without modification. - -## Alternative Considered: DashMap - -The report suggested `DashMap` as an alternative for lock-free concurrent reads. However, this was rejected in favor of the simpler Arc-based approach because: - -1. **Simplicity**: Arc + RwLock is easier to understand and maintain -2. **Sufficient**: The current read/write pattern doesn't require lock-free data structures -3. **Dependency**: Avoids adding another dependency -4. **Performance**: Arc cloning is already extremely cheap (atomic increment) - -If profiling shows RwLock contention in the future, DashMap can be reconsidered. - -## Implementation Status - -**Partially Applied**: Due to file watcher conflicts (likely rust-analyzer or rustfmt), the changes were documented here but not all applied to the source files. - -**To complete implementation**: -1. Disable file watchers temporarily -2. Apply all changes listed above -3. Run `cargo fmt` to format the code -4. Run tests to verify correctness -5. Re-enable file watchers - -## References - -- Project analysis report identifying clone overhead -- Rust `Arc` documentation: https://doc.rust-lang.org/std/sync/struct.Arc.html -- Copy-on-write pattern in Rust -- RwLock best practices diff --git a/README.md b/README.md deleted file mode 100644 index 3dd8dce..0000000 --- a/README.md +++ /dev/null @@ -1,234 +0,0 @@ -# OWLEN - -> Terminal-native assistant for running local language models with a comfortable TUI. - -![Status](https://img.shields.io/badge/status-alpha-yellow) -![Version](https://img.shields.io/badge/version-0.2.0-blue) -![Rust](https://img.shields.io/badge/made_with-Rust-ffc832?logo=rust&logoColor=white) -![License](https://img.shields.io/badge/license-AGPL--3.0-blue) - -## What Is OWLEN? - -OWLEN is a Rust-powered, terminal-first interface for interacting with local and cloud -language models. It provides a responsive chat workflow that now routes through a -multi-provider manager—handling local Ollama, Ollama Cloud, and future MCP-backed providers— -with a focus on developer productivity, vim-style navigation, and seamless session -management—all without leaving your terminal. - -## Alpha Status - -This project is currently in **alpha** and under active development. Core features are functional, but expect occasional bugs and breaking changes. Feedback, bug reports, and contributions are very welcome! - -## Screenshots - -![OWLEN TUI Layout](images/layout.png) - -The refreshed chrome introduces a cockpit-style header with live gradient gauges for context and cloud usage, plus glassy panels that keep vim-inspired navigation easy to follow. See more screenshots in the [`images/`](images/) directory. - -## Features - -- **Vim-style Navigation**: Normal, editing, visual, and command modes. -- **Streaming Responses**: Real-time token streaming from Ollama. -- **Advanced Text Editing**: Multi-line input, history, and clipboard support. -- **Session Management**: Save, load, and manage conversations. -- **Code Side Panel**: Switch to code mode (`:mode code`) and open files inline with `:open ` for LLM-assisted coding. -- **Cockpit Header**: Gradient context and cloud usage bars with live quota bands and provider fallbacks. -- **Theming System**: 10 built-in themes and support for custom themes. -- **Modular Architecture**: Extensible provider system orchestrated by the new `ProviderManager`, ready for additional MCP-backed providers. -- **Dual-Source Model Picker**: Merge local and cloud catalogues with real-time availability badges powered by the background health worker. -- **Non-Blocking UI Loop**: Asynchronous generation tasks and provider health checks run off-thread, keeping the TUI responsive even while streaming long replies. -- **Guided Setup**: `owlen config doctor` upgrades legacy configs and verifies your environment in seconds. - -## Repository Automation - -Owlen now ships with Git-aware automation helpers so you can review code and stage commits without leaving the terminal: - -- **CLI** – `owlen repo commit-template` renders a conventional commit scaffolding from the staged diff (`--working-tree` inspects unstaged changes), while `owlen repo review` summarises the current branch or a GitHub pull request. Provide `--owner`, `--repo`, and `--number` to fetch remote diffs; the command picks up credentials from `GITHUB_TOKEN` (override with `--token-env` or `--token`). -- **TUI** – `:repo template` injects the generated template into the conversation stream, and `:repo review [--base BRANCH] [--head REF]` produces a Markdown review of local changes. The results appear as system messages so you can follow up with an LLM turn or copy them directly into a GitHub comment. -- **Automation APIs** – Under the hood, `owlen-core::automation::repo` exposes reusable builders (`RepoAutomation`, `CommitTemplate`, `PullRequestReview`) that mirror the Claude Code workflow style. They provide JSON-serialisable checklists, workflow steps, and heuristics that highlight risky changes (e.g., new `unwrap()` calls, unchecked `unsafe` blocks, or absent tests). - -Add a personal access token with `repo` scope to unlock GitHub diff fetching. Enterprise installations can point at a custom API host with the `--api-endpoint` flag. - -## Upgrading to v0.2 - -- **Local + Cloud resiliency**: Owlen now distinguishes the on-device daemon from Ollama Cloud and gracefully falls back to local if the hosted key is missing or unauthorized. Cloud requests include `Authorization: Bearer ` and reuse the canonical `https://ollama.com` base URL so you no longer hit 401 loops. -- **Context + quota cockpit**: The header shows `context used / window (percentage)` and a second gauge for hourly/weekly cloud token usage. Configure soft limits via `providers.ollama_cloud.hourly_quota_tokens` and `weekly_quota_tokens`; Owlen tracks consumption locally even when the provider omits token counters. -- **Web search tooling**: When cloud is enabled, models can call the spec-compliant `web_search` tool automatically. Toggle availability at runtime with `:web on` / `:web off` if you need a local-only session. -- **Docs & config parity**: Ship-ready config templates now include per-provider `list_ttl_secs` and `default_context_window` values, plus explicit `OLLAMA_API_KEY` guidance. Run `owlen config doctor` after upgrading from v0.1 to normalize legacy keys and receive deprecation warnings for `OLLAMA_CLOUD_API_KEY` and `OWLEN_OLLAMA_CLOUD_API_KEY`. -- **Runtime toggles**: Use `:web on` / `:web off` in the TUI or `owlen providers web --enable/--disable` from the CLI to expose or hide the `web_search` tool without editing `config.toml`. - -## MCP Naming & Reference Bundles - -Owlen enforces spec-compliant tool identifiers: stick to `^[A-Za-z0-9_-]{1,64}$`, avoid dotted names, and keep identifiers short so the host can qualify them when multiple servers are present.citeturn11search0 Define your tools with underscores or hyphens (for example, `web_search`, `filesystem_read`, `notion_query`) and treat any legacy dotted forms as incompatible. - -Modern MCP hosts converge on a common bundle of connectors that cover three broad categories: local operations (filesystem, terminal, git, structured HTTP fetch, browser automation), compute sandboxes (Python, notebook adapters, sequential-thinking planners, test runners), and SaaS integrations (GitHub issues, Notion workspaces, Slack, Stripe, Sentry, Google Drive, Zapier-style automation, design system search).citeturn12search3turn12search10 Owlen’s configuration examples mirror that baseline so a fresh install can wire up the same capabilities without additional mapping. - -To replicate the reference bundle today: - -1. Enable the built-in tools that ship with Owlen (`web_search`, filesystem resource APIs, execution sandboxes). -2. Add external servers under `[mcp_servers]`, keeping names spec-compliant (e.g., `filesystem`, `terminal`, `git`, `browser`, `http_fetch`, `python`, `notebook`, `sequential_thinking`, `sentry`, `notion`, `slack`, `stripe`, `google_drive`, `memory_bank`, `automation_hub`). -3. Qualify tool identifiers in prompts and configs using the `{server}__{tool}` pattern once multiple servers contribute overlapping operations (`filesystem__read`, `browser__request`, `notion__query_database`). - -See the updated MCP guide in `docs/` for detailed installation commands, environment variables, and health checks for each connector. The documentation set below walks through configuration and runtime toggles for `web_search` and the rest of the reference bundle. - -## Security & Privacy - -Owlen is designed to keep data local by default while still allowing controlled access to remote tooling. - -- **Local-first execution**: All LLM calls flow through the bundled MCP LLM server which talks to a local Ollama instance. If the server is unreachable, Owlen stays usable in “offline mode” and surfaces clear recovery instructions. -- **Sandboxed tooling**: Code execution runs in Docker according to the MCP Code Server settings, and future releases will extend this to other OS-level sandboxes (`sandbox-exec` on macOS, Windows job objects). -- **Session storage**: Conversations are stored under the platform data directory and can be encrypted at rest. Set `privacy.encrypt_local_data = true` in `config.toml` to enable AES-GCM storage backed by an Owlen-managed secret key—no passphrase entry required. -- **Network access**: No telemetry is sent. The only outbound requests occur when you explicitly enable remote tooling (e.g., web search) or configure a cloud LLM provider. Each tool is opt-in via `privacy` and `tools` configuration sections. -- **Config migrations**: Every saved `config.toml` carries a schema version and is upgraded automatically; deprecated keys trigger warnings so security-related settings are not silently ignored. - -## Getting Started - -### Prerequisites -- Rust 1.75+ and Cargo. -- A running Ollama instance. -- A terminal that supports 256 colors. - -### Installation - -Pick the option that matches your platform and appetite for source builds: - -| Platform | Package / Command | Notes | -| --- | --- | --- | -| Arch Linux | `yay -S owlen-git` | Builds from the latest `dev` branch via AUR. | -| Other Linux | `cargo install --path crates/owlen-cli --locked --force` | Requires Rust 1.75+ and a running Ollama daemon. | -| macOS | `cargo install --path crates/owlen-cli --locked --force` | macOS 12+ tested. Install Ollama separately (`brew install ollama`). The binary links against the system OpenSSL – ensure Command Line Tools are installed. | -| Windows (experimental) | `cargo install --path crates/owlen-cli --locked --force` | Enable the GNU toolchain (`rustup target add x86_64-pc-windows-gnu`) and install Ollama for Windows preview builds. Some optional tools (e.g., Docker-based code execution) are currently disabled. | - -If you prefer containerised builds, use the provided `Dockerfile` as a base image and copy out `target/release/owlen`. - -Run the helper scripts to sanity-check platform coverage: - -```bash -# Windows compatibility smoke test (GNU toolchain) -scripts/check-windows.sh - -# Reproduce CI packaging locally (choose a target from .woodpecker.yml) -dev/local_build.sh x86_64-unknown-linux-gnu -``` - -> **Tip (macOS):** On the first launch macOS Gatekeeper may quarantine the binary. Clear the attribute (`xattr -d com.apple.quarantine $(which owlen)`) or build from source locally to avoid notarisation prompts. - -### Running OWLEN - -Make sure Ollama is running, then launch the application: -```bash -owlen -``` -If you built from source without installing, you can run it with: -```bash -./target/release/owlen -``` - -### Updating - -Owlen does not auto-update. Run `owlen upgrade` at any time to print the recommended manual steps (pull the repository and reinstall with `cargo install --path crates/owlen-cli --force`). Arch Linux users can update via the `owlen-git` AUR package. - -## Using the TUI - -OWLEN uses a modal, vim-inspired interface. Press `F1` (available from any mode) or `?` in Normal mode to view the help screen with all keybindings. - -- **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`. -- **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`. -- **Command Mode**: Enter with `:`. Access commands like `:quit`, `:w`, `:session save`, `:theme`. -- **Quick Exit**: Press `Ctrl+C` twice in Normal mode to quit quickly (first press still cancels active generations). -- **Tutorial Command**: Type `:tutorial` any time for a quick summary of the most important keybindings. -- **MCP Slash Commands**: Owlen auto-registers zero-argument MCP tools as slash commands—type `/mcp__github__list_prs` (for example) to pull remote context directly into the chat log. - -### Keymaps - -Two built-in keymaps ship with Owlen: - -- `vim` (default) – the existing modal bindings documented above. -- `emacs` – bindings centred around `Alt+X`, `Ctrl+Space`, and `Alt+O` shortcuts with Emacs-style submit (`Ctrl+Enter`). - -Switch at runtime with `:keymap vim` or `:keymap emacs`. Persist your choice by setting `ui.keymap_profile = "emacs"` (or `"vim"`) in `config.toml`. If you prefer a fully custom layout, point `ui.keymap_path` at a TOML file using the same format as [`crates/owlen-tui/keymap.toml`](crates/owlen-tui/keymap.toml); the new emacs profile file [`crates/owlen-tui/keymap_emacs.toml`](crates/owlen-tui/keymap_emacs.toml) is a useful template. - -Model discovery commands worth remembering: - -- `:models --local` or `:models --cloud` jump directly to the corresponding section in the picker. -- `:cloud setup [--force-cloud-base-url]` stores your cloud API key without clobbering an existing local base URL (unless you opt in with the flag). -- `:limits` prints the locally tracked hourly/weekly token totals for each provider and mirrors the values shown in the chat header. -When a catalogue is unreachable, Owlen now tags the picker with `Local unavailable` / `Cloud unavailable` so you can recover without guessing. - -## Documentation - -For more detailed information, please refer to the following documents: - -- **[CONTRIBUTING.md](CONTRIBUTING.md)**: Guidelines for contributing to the project. -- **[CHANGELOG.md](CHANGELOG.md)**: A log of changes for each version. -- **[docs/architecture.md](docs/architecture.md)**: An overview of the project's architecture. -- **[docs/troubleshooting.md](docs/troubleshooting.md)**: Help with common issues. -- **[docs/repo-map.md](docs/repo-map.md)**: Snapshot of the workspace layout and key crates. -- **[docs/provider-implementation.md](docs/provider-implementation.md)**: Trait-level details for implementing providers. -- **[docs/adding-providers.md](docs/adding-providers.md)**: Step-by-step checklist for wiring a provider into the multi-provider architecture and test suite. -- **[docs/tui-ux-playbook.md](docs/tui-ux-playbook.md)**: Design principles, modal ergonomics, and keybinding guidance for the TUI. -- **Experimental providers staging area**: [crates/providers/experimental/README.md](crates/providers/experimental/README.md) records the placeholder crates (OpenAI, Anthropic, Gemini) and their current status. -- **[docs/platform-support.md](docs/platform-support.md)**: Current OS support matrix and cross-check instructions. - -## Developer Tasks - -- `cargo xtask screenshots` regenerates deterministic ANSI dumps (and, when - `chafa` is available, PNG renders) for the documentation gallery. Use - `--no-png` to skip the PNG step or `--output ` to redirect the output. - -## Conversation Compression - -Owlen automatically compacts older turns once a chat crosses the configured -token threshold. The behaviour is controlled by the `[chat]` section in -`config.toml` (enabled by default via `chat.auto_compress = true`). - -- Launch the TUI with `--no-auto-compress` to opt out for a single run. -- Inside the app, `:compress now` generates an on-demand summary, while - `:compress auto on|off` flips the automatic mode and persists the change. -- Each compression pass emits a system summary that carries metadata about the - retained messages, strategy, and estimated token savings. - -## Configuration - -OWLEN stores its configuration in the standard platform-specific config directory: - -| Platform | Location | -|----------|----------| -| Linux | `~/.config/owlen/config.toml` | -| macOS | `~/Library/Application Support/owlen/config.toml` | -| Windows | `%APPDATA%\owlen\config.toml` | - -Use `owlen config init` to scaffold a fresh configuration (pass `--force` to overwrite an existing file), `owlen config path` to print the resolved location, and `owlen config doctor` to migrate legacy layouts automatically. -You can also add custom themes alongside the config directory (e.g., `~/.config/owlen/themes/`). - -See the [themes/README.md](themes/README.md) for more details on theming. - -## Testing - -Owlen uses standard Rust tooling for verification. Run the full test suite with: - -```bash -cargo test -``` - -Unit tests cover the command palette state machine, agent response parsing, and key MCP abstractions. Formatting and lint checks can be run with `cargo fmt --all` and `cargo clippy` respectively. - -## Roadmap - -Upcoming milestones focus on feature parity with modern code assistants while keeping Owlen local-first: - -1. **Phase 11 – MCP client enhancements**: `owlen mcp add/list/remove`, resource references (`@github:issue://123`), and MCP prompt slash commands. -2. **Phase 12 – Approval & sandboxing**: Three-tier approval modes plus platform-specific sandboxes (Docker, `sandbox-exec`, Windows job objects). -3. **Phase 13 – Project documentation system**: Automatic `OWLEN.md` generation, contextual updates, and nested project support. -4. **Phase 15 – Provider expansion**: OpenAI, Anthropic, and other cloud providers layered onto the existing Ollama-first architecture. - -See `AGENTS.md` for the long-form roadmap and design notes. - -## Contributing - -Contributions are highly welcome! Please see our **[Contributing Guide](CONTRIBUTING.md)** for details on how to get started, including our code style, commit conventions, and pull request process. - -## License - -This project is licensed under the GNU Affero General Public License v3.0. See the [LICENSE](LICENSE) file for details. -For commercial or proprietary integrations that cannot adopt AGPL, please reach out to the maintainers to discuss alternative licensing arrangements. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index ce6a456..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,40 +0,0 @@ -# Security Policy - -## Supported Versions - -We are currently in a pre-release phase, so only the latest version is actively supported. As we move towards a 1.0 release, this policy will be updated with specific version support. - -| Version | Supported | -| ------- | ------------------ | -| < 1.0 | :white_check_mark: | - -## Reporting a Vulnerability - -The Owlen team and community take all security vulnerabilities seriously. Thank you for improving the security of our project. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. - -To report a security vulnerability, please email the project lead at [security@owlibou.com](mailto:security@owlibou.com) with a detailed description of the issue, the steps to reproduce it, and any affected versions. - -You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible, depending on the complexity of the issue. - -Please do not report security vulnerabilities through public GitHub issues. - -## Design Overview - -Owlen ships with a local-first architecture: - -- **Process isolation** – The TUI speaks to language models through a separate MCP LLM server. Tool execution (code, web, filesystem) occurs in dedicated MCP processes so a crash or hang cannot take down the UI. -- **Sandboxing** – The MCP Code Server executes snippets in Docker containers. Upcoming releases will extend this to platform sandboxes (`sandbox-exec` on macOS, Windows job objects) as described in our roadmap. -- **Network posture** – No telemetry is emitted. The application only reaches the network when a user explicitly enables remote tools (web search, remote MCP servers) or configures cloud providers. All tools require allow-listing in `config.toml`. - -## Data Handling - -- **Sessions** – Conversations are stored in the user’s data directory (`~/.local/share/owlen` on Linux, equivalent paths on macOS/Windows). Enable `privacy.encrypt_local_data = true` to wrap the session store in AES-GCM encryption using an Owlen-managed key—no interactive passphrase prompts are required. -- **Credentials** – API tokens are resolved from the config file or environment variables at runtime and are never written to logs. -- **Remote calls** – When remote search or cloud LLM tooling is on, only the minimum payload (prompt, tool arguments) is sent. All outbound requests go through the MCP servers so they can be audited or disabled centrally. - -## Supply-Chain Safeguards - -- The repository includes a git `pre-commit` configuration that runs `cargo fmt`, `cargo check`, and `cargo clippy -- -D warnings` on every commit. -- Pull requests generated with the assistance of AI tooling must receive manual maintainer review before merging. Contributors are asked to declare AI involvement in their PR description so maintainers can double-check the changes. - -Additional recommendations for operators (e.g., running Owlen on shared systems) are maintained in `docs/security.md` (planned) and the issue tracker. diff --git a/SQLX_MIGRATION_GUIDE.md b/SQLX_MIGRATION_GUIDE.md deleted file mode 100644 index ed7486a..0000000 --- a/SQLX_MIGRATION_GUIDE.md +++ /dev/null @@ -1,197 +0,0 @@ -# SQLx 0.7 to 0.8 Migration Guide for Owlen - -## Executive Summary - -The Owlen project has been successfully upgraded from SQLx 0.7 to SQLx 0.8. The migration was straightforward as Owlen uses SQLite, which is not affected by the security vulnerability CVE-2024-0363. - -## Key Changes Made - -### 1. Cargo.toml Update - -**Before (SQLx 0.7):** -```toml -sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "macros", "uuid", "chrono", "migrate"] } -``` - -**After (SQLx 0.8):** -```toml -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "sqlite", "macros", "uuid", "chrono", "migrate"] } -``` - -**Key change:** Split `runtime-tokio-rustls` into `runtime-tokio` and `tls-rustls` - -## Important Notes for Owlen - -### 1. Security Status - -- **CVE-2024-0363 (Binary Protocol Misinterpretation)**: This vulnerability **DOES NOT AFFECT SQLite users** - - Only affects PostgreSQL and MySQL that use binary network protocols - - SQLite uses an in-process C API, not a network protocol - - No security risk for Owlen's SQLite implementation - -### 2. Date/Time Handling - -Owlen uses `chrono` types directly, not through SQLx's query macros for datetime columns. The current implementation: -- Uses `INTEGER` columns for timestamps (Unix epoch seconds) -- Converts between `SystemTime` and epoch seconds manually -- No changes needed for datetime handling - -### 3. Database Schema - -The existing migrations work without modification: -- `/crates/owlen-core/migrations/0001_create_conversations.sql` -- `/crates/owlen-core/migrations/0002_create_secure_items.sql` - -### 4. Offline Mode Changes - -For CI/CD pipelines: -- Offline mode is now always enabled (no separate flag needed) -- Use `SQLX_OFFLINE=true` environment variable to force offline builds -- Run `cargo sqlx prepare --workspace` to regenerate query metadata -- The `.sqlx` directory should be committed to version control - -## Testing Checklist - -After the upgrade, perform these tests: - -- [ ] Run all unit tests: `cargo test --all` -- [ ] Test database operations: - - [ ] Create new conversation - - [ ] Save existing conversation - - [ ] Load conversation by ID - - [ ] List all conversations - - [ ] Search conversations - - [ ] Delete conversation -- [ ] Test migrations: `cargo sqlx migrate run` -- [ ] Test offline compilation (CI simulation): - ```bash - rm -rf .sqlx - cargo sqlx prepare --workspace - SQLX_OFFLINE=true cargo build --release - ``` - -## Migration Code Patterns - -### Connection Pool Setup (No Changes Required) - -The connection pool setup remains identical: - -```rust -use sqlx::sqlite::{SqlitePool, SqlitePoolOptions, SqliteConnectOptions}; - -let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path))? - .create_if_missing(true) - .journal_mode(SqliteJournalMode::Wal) - .synchronous(SqliteSynchronous::Normal); - -let pool = SqlitePoolOptions::new() - .max_connections(5) - .connect_with(options) - .await?; -``` - -### Query Execution (No Changes Required) - -Standard queries work the same: - -```rust -sqlx::query( - r#" - INSERT INTO conversations (id, name, description, model, message_count, created_at, updated_at, data) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - description = excluded.description, - model = excluded.model, - message_count = excluded.message_count, - updated_at = excluded.updated_at, - data = excluded.data - "# -) -.bind(&id) -.bind(&name) -.bind(&description) -.bind(&model) -.bind(message_count) -.bind(created_at) -.bind(updated_at) -.bind(&data) -.execute(&self.pool) -.await?; -``` - -### Transaction Handling (No Changes Required) - -```rust -let mut tx = pool.begin().await?; - -sqlx::query("INSERT INTO users (name) VALUES (?)") - .bind("Alice") - .execute(&mut *tx) - .await?; - -tx.commit().await?; -``` - -## Performance Improvements in 0.8 - -1. **SQLite-specific fixes**: Version 0.8.6 fixed a performance regression for SQLite -2. **Better connection pooling**: More efficient connection reuse -3. **Improved compile-time checking**: Faster query validation - -## Common Pitfalls to Avoid - -1. **Feature flag splitting**: Don't forget to split `runtime-tokio-rustls` into two separate features -2. **Dependency conflicts**: Check for `libsqlite3-sys` version conflicts with `cargo tree -i libsqlite3-sys` -3. **Offline mode**: Remember that offline mode is always on - no need to enable it separately - -## Future Considerations - -### If Moving to query! Macro - -If you decide to use compile-time checked queries in the future: - -```rust -// Instead of manual query building -let row = sqlx::query("SELECT * FROM conversations WHERE id = ?") - .bind(&id) - .fetch_one(&pool) - .await?; - -// Use compile-time checked queries -let conversation = sqlx::query_as!( - ConversationRow, - "SELECT * FROM conversations WHERE id = ?", - id -) -.fetch_one(&pool) -.await?; -``` - -### If Adding DateTime Columns - -If you add proper DATETIME columns in the future (instead of INTEGER timestamps): - -```rust -// With SQLx 0.8 + chrono feature, you'll use time crate types -use time::PrimitiveDateTime; - -// Instead of chrono::NaiveDateTime -#[derive(sqlx::FromRow)] -struct MyModel { - created_at: PrimitiveDateTime, // Not chrono::NaiveDateTime -} -``` - -## Verification Steps - -1. **Build successful**: ✅ SQLx 0.8 compiles without errors -2. **Tests pass**: Run `cargo test -p owlen-core` to verify -3. **Migrations work**: Run `cargo sqlx migrate info` to check migration status -4. **Runtime works**: Start the application and perform basic operations - -## Resources - -- [SQLx 0.8 Release Notes](https://github.com/launchbadge/sqlx/releases/tag/v0.8.0) -- [SQLx Migration Guide](https://github.com/launchbadge/sqlx/blob/main/CHANGELOG.md) -- [CVE-2024-0363 Details](https://rustsec.org/advisories/RUSTSEC-2024-0363) \ No newline at end of file diff --git a/agents.md b/agents.md deleted file mode 100644 index 0e91a28..0000000 --- a/agents.md +++ /dev/null @@ -1,10 +0,0 @@ -# Agents Upgrade Plan - -- [x] feat: support multimodal inputs (images, rich artifacts) and preview panes so non-text context matches Codex CLI image handling and Claude Code’s artifact outputs -- [x] feat: integrate repository automation (GitHub PR review, commit templating, Claude SDK-style automation APIs) to reach parity with Codex CLI’s GitHub integration and Claude Code’s CLI/SDK automation -- feat: implement Codex-style non-blocking TUI so commands remain usable while backend work runs: - 1. Add an `AppEvent` channel and dispatch layer in `crates/owlen-tui/src/app/mod.rs` that mirrors the `tokio::select!` loop used in `codex-rs/tui/src/app.rs:190-197` to multiplex UI input, session events, and background updates without blocking redraws. - 2. Refactor `ChatApp::process_pending_llm_request` and related helpers to spawn tasks that submit prompts via `SessionController` and stream results back through the new channel, following `codex-rs/tui/src/chatwidget/agent.rs:16-61` so the request lifecycle no longer stalls the UI thread. - 3. Track active-turn state plus queued inputs inside `ChatApp` and surface them through the status pane—similar to `codex-rs/tui/src/chatwidget.rs:1105-1132` and `codex-rs/tui/src/bottom_pane/mod.rs:334-352,378-383`—so users can enqueue commands/slash actions while a turn is executing. - 4. Introduce a frame requester/draw scheduler (accessible from `ChatApp` and background tasks) that coalesces redraws like `codex-rs/tui/src/tui.rs:234-390`, ensuring notifications, queue updates, and streaming deltas trigger renders without blocking the event loop. - 5. Extend input handling and regression tests to cover concurrent queued messages, cancellation, and post-turn flushing, echoing the completion hooks in `codex-rs/tui/src/chatwidget.rs:436-455` and keeping `/help` and command palette responsive under load. diff --git a/config.toml b/config.toml deleted file mode 100644 index a236c50..0000000 --- a/config.toml +++ /dev/null @@ -1,35 +0,0 @@ -[general] -default_provider = "ollama_local" -default_model = "llama3.2:latest" - -[privacy] -encrypt_local_data = true - -[providers.ollama_local] -enabled = true -provider_type = "ollama" -base_url = "http://localhost:11434" -list_ttl_secs = 60 -default_context_window = 8192 - -[providers.ollama_cloud] -enabled = false -provider_type = "ollama_cloud" -base_url = "https://ollama.com" -api_key_env = "OLLAMA_API_KEY" -hourly_quota_tokens = 50000 -weekly_quota_tokens = 250000 -list_ttl_secs = 60 -default_context_window = 8192 - -[providers.openai] -enabled = false -provider_type = "openai" -base_url = "https://api.openai.com/v1" -api_key_env = "OPENAI_API_KEY" - -[providers.anthropic] -enabled = false -provider_type = "anthropic" -base_url = "https://api.anthropic.com/v1" -api_key_env = "ANTHROPIC_API_KEY" diff --git a/crates/mcp/client/Cargo.toml b/crates/mcp/client/Cargo.toml deleted file mode 100644 index 657bdd6..0000000 --- a/crates/mcp/client/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "owlen-mcp-client" -version = "0.1.0" -edition.workspace = true -description = "Dedicated MCP client library for Owlen, exposing remote MCP server communication" -license = "AGPL-3.0" - -[dependencies] -owlen-core = { path = "../../owlen-core" } - -[features] -default = [] diff --git a/crates/mcp/client/src/lib.rs b/crates/mcp/client/src/lib.rs deleted file mode 100644 index f5afb7c..0000000 --- a/crates/mcp/client/src/lib.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Owlen MCP client library. -//! -//! This crate provides a thin façade over the remote MCP client implementation -//! inside `owlen-core`. It re‑exports the most useful types so downstream -//! crates can depend only on `owlen-mcp-client` without pulling in the entire -//! core crate internals. - -pub use owlen_core::config::{McpConfigScope, ScopedMcpServer}; -pub use owlen_core::mcp::remote_client::RemoteMcpClient; -pub use owlen_core::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse}; - -// Re‑export the core Provider trait so that the MCP client can also be used as an LLM provider. -pub use owlen_core::Provider as McpProvider; - -// Note: The `RemoteMcpClient` type provides its own `new` constructor in the core -// crate. Users can call `RemoteMcpClient::new()` directly. No additional wrapper -// is needed here. diff --git a/crates/mcp/code-server/Cargo.toml b/crates/mcp/code-server/Cargo.toml deleted file mode 100644 index b595a83..0000000 --- a/crates/mcp/code-server/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "owlen-mcp-code-server" -version = "0.1.0" -edition.workspace = true -description = "MCP server exposing safe code execution tools for Owlen" -license = "AGPL-3.0" - -[dependencies] -owlen-core = { path = "../../owlen-core" } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -anyhow = { workspace = true } -async-trait = { workspace = true } -bollard = "0.17" -tempfile = { workspace = true } -uuid = { workspace = true } -futures = { workspace = true } - -[lib] -name = "owlen_mcp_code_server" -path = "src/lib.rs" diff --git a/crates/mcp/code-server/src/lib.rs b/crates/mcp/code-server/src/lib.rs deleted file mode 100644 index 2008c87..0000000 --- a/crates/mcp/code-server/src/lib.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! MCP server exposing code execution tools with Docker sandboxing. -//! -//! This server provides: -//! - compile_project: Build projects (Rust, Node.js, Python) -//! - run_tests: Execute test suites -//! - format_code: Run code formatters -//! - lint_code: Run linters - -pub mod sandbox; -pub mod tools; - -use owlen_core::mcp::protocol::{ - ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError, - RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, methods, -}; -use owlen_core::tools::{Tool, ToolResult}; -use serde_json::{Value, json}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt}; - -use tools::{CompileProjectTool, FormatCodeTool, LintCodeTool, RunTestsTool}; - -/// Tool registry for the code server -#[allow(dead_code)] -struct ToolRegistry { - tools: HashMap>, -} - -#[allow(dead_code)] -impl ToolRegistry { - fn new() -> Self { - let mut tools: HashMap> = HashMap::new(); - tools.insert( - "compile_project".to_string(), - Box::new(CompileProjectTool::new()), - ); - tools.insert("run_tests".to_string(), Box::new(RunTestsTool::new())); - tools.insert("format_code".to_string(), Box::new(FormatCodeTool::new())); - tools.insert("lint_code".to_string(), Box::new(LintCodeTool::new())); - Self { tools } - } - - fn list_tools(&self) -> Vec { - self.tools - .values() - .map(|tool| owlen_core::mcp::McpToolDescriptor { - name: tool.name().to_string(), - description: tool.description().to_string(), - input_schema: tool.schema(), - requires_network: tool.requires_network(), - requires_filesystem: tool.requires_filesystem(), - }) - .collect() - } - - async fn execute(&self, name: &str, args: Value) -> Result { - self.tools - .get(name) - .ok_or_else(|| format!("Tool not found: {}", name))? - .execute(args) - .await - .map_err(|e| e.to_string()) - } -} - -#[allow(dead_code)] -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let mut stdin = io::BufReader::new(io::stdin()); - let mut stdout = io::stdout(); - - let registry = Arc::new(ToolRegistry::new()); - - loop { - let mut line = String::new(); - match stdin.read_line(&mut line).await { - Ok(0) => break, // EOF - Ok(_) => { - let req: RpcRequest = match serde_json::from_str(&line) { - Ok(r) => r, - Err(e) => { - let err = RpcErrorResponse::new( - RequestId::Number(0), - RpcError::parse_error(format!("Parse error: {}", e)), - ); - let s = serde_json::to_string(&err)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - - let resp = handle_request(req.clone(), registry.clone()).await; - match resp { - Ok(r) => { - let s = serde_json::to_string(&r)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - Err(e) => { - let err = RpcErrorResponse::new(req.id.clone(), e); - let s = serde_json::to_string(&err)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - } - } - Err(e) => { - eprintln!("Error reading stdin: {}", e); - break; - } - } - } - Ok(()) -} - -#[allow(dead_code)] -async fn handle_request( - req: RpcRequest, - registry: Arc, -) -> Result { - match req.method.as_str() { - methods::INITIALIZE => { - let params: InitializeParams = - serde_json::from_value(req.params.unwrap_or_else(|| json!({}))) - .map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?; - if !params.protocol_version.eq(PROTOCOL_VERSION) { - return Err(RpcError::new( - ErrorCode::INVALID_REQUEST, - format!( - "Incompatible protocol version. Client: {}, Server: {}", - params.protocol_version, PROTOCOL_VERSION - ), - )); - } - let result = InitializeResult { - protocol_version: PROTOCOL_VERSION.to_string(), - server_info: ServerInfo { - name: "owlen-mcp-code-server".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: ServerCapabilities { - supports_tools: Some(true), - supports_resources: Some(false), - supports_streaming: Some(false), - }, - }; - let payload = serde_json::to_value(result).map_err(|e| { - RpcError::internal_error(format!("Failed to serialize initialize result: {}", e)) - })?; - Ok(RpcResponse::new(req.id, payload)) - } - methods::TOOLS_LIST => { - let tools = registry.list_tools(); - Ok(RpcResponse::new(req.id, json!(tools))) - } - methods::TOOLS_CALL => { - let call = serde_json::from_value::( - req.params.unwrap_or_else(|| json!({})), - ) - .map_err(|e| RpcError::invalid_params(format!("Invalid tool call: {}", e)))?; - - let result: ToolResult = registry - .execute(&call.name, call.arguments) - .await - .map_err(|e| RpcError::internal_error(format!("Tool execution failed: {}", e)))?; - - let resp = owlen_core::mcp::McpToolResponse { - name: call.name, - success: result.success, - output: result.output, - metadata: result.metadata, - duration_ms: result.duration.as_millis() as u128, - }; - let payload = serde_json::to_value(resp).map_err(|e| { - RpcError::internal_error(format!("Failed to serialize tool response: {}", e)) - })?; - Ok(RpcResponse::new(req.id, payload)) - } - _ => Err(RpcError::method_not_found(&req.method)), - } -} diff --git a/crates/mcp/code-server/src/sandbox.rs b/crates/mcp/code-server/src/sandbox.rs deleted file mode 100644 index cc045ba..0000000 --- a/crates/mcp/code-server/src/sandbox.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Docker-based sandboxing for secure code execution - -use anyhow::{Context, Result}; -use bollard::Docker; -use bollard::container::{ - Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions, - WaitContainerOptions, -}; -use bollard::models::{HostConfig, Mount, MountTypeEnum}; -use std::collections::HashMap; -use std::path::Path; - -/// Result of executing code in a sandbox -#[derive(Debug, Clone)] -pub struct ExecutionResult { - pub stdout: String, - pub stderr: String, - pub exit_code: i64, - pub timed_out: bool, -} - -/// Docker-based sandbox executor -pub struct Sandbox { - docker: Docker, - memory_limit: i64, - cpu_quota: i64, - timeout_secs: u64, -} - -impl Sandbox { - /// Create a new sandbox with default resource limits - pub fn new() -> Result { - let docker = - Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?; - - Ok(Self { - docker, - memory_limit: 512 * 1024 * 1024, // 512MB - cpu_quota: 50000, // 50% of one core - timeout_secs: 30, - }) - } - - /// Execute a command in a sandboxed container - pub async fn execute( - &self, - image: &str, - cmd: &[&str], - workspace: Option<&Path>, - env: HashMap, - ) -> Result { - let container_name = format!("owlen-sandbox-{}", uuid::Uuid::new_v4()); - - // Prepare volume mount if workspace provided - let mounts = if let Some(ws) = workspace { - vec![Mount { - target: Some("/workspace".to_string()), - source: Some(ws.to_string_lossy().to_string()), - typ: Some(MountTypeEnum::BIND), - read_only: Some(false), - ..Default::default() - }] - } else { - vec![] - }; - - // Create container config - let host_config = HostConfig { - memory: Some(self.memory_limit), - cpu_quota: Some(self.cpu_quota), - network_mode: Some("none".to_string()), // No network access - mounts: Some(mounts), - auto_remove: Some(true), - ..Default::default() - }; - - let config = Config { - image: Some(image.to_string()), - cmd: Some(cmd.iter().map(|s| s.to_string()).collect()), - working_dir: Some("/workspace".to_string()), - env: Some(env.iter().map(|(k, v)| format!("{}={}", k, v)).collect()), - host_config: Some(host_config), - attach_stdout: Some(true), - attach_stderr: Some(true), - tty: Some(false), - ..Default::default() - }; - - // Create container - let container = self - .docker - .create_container( - Some(CreateContainerOptions { - name: container_name.clone(), - ..Default::default() - }), - config, - ) - .await - .context("Failed to create container")?; - - // Start container - self.docker - .start_container(&container.id, None::>) - .await - .context("Failed to start container")?; - - // Wait for container with timeout - let wait_result = - tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), async { - let mut wait_stream = self - .docker - .wait_container(&container.id, None::>); - - use futures::StreamExt; - if let Some(result) = wait_stream.next().await { - result - } else { - Err(bollard::errors::Error::IOError { - err: std::io::Error::other("Container wait stream ended unexpectedly"), - }) - } - }) - .await; - - let (exit_code, timed_out) = match wait_result { - Ok(Ok(result)) => (result.status_code, false), - Ok(Err(e)) => { - eprintln!("Container wait error: {}", e); - (1, false) - } - Err(_) => { - // Timeout - kill the container - let _ = self - .docker - .kill_container( - &container.id, - None::>, - ) - .await; - (124, true) - } - }; - - // Get logs - let logs = self.docker.logs( - &container.id, - Some(bollard::container::LogsOptions:: { - stdout: true, - stderr: true, - ..Default::default() - }), - ); - - use futures::StreamExt; - let mut stdout = String::new(); - let mut stderr = String::new(); - - let log_result = tokio::time::timeout(std::time::Duration::from_secs(5), async { - let mut logs = logs; - while let Some(log) = logs.next().await { - match log { - Ok(bollard::container::LogOutput::StdOut { message }) => { - stdout.push_str(&String::from_utf8_lossy(&message)); - } - Ok(bollard::container::LogOutput::StdErr { message }) => { - stderr.push_str(&String::from_utf8_lossy(&message)); - } - _ => {} - } - } - }) - .await; - - if log_result.is_err() { - eprintln!("Timeout reading container logs"); - } - - // Remove container (auto_remove should handle this, but be explicit) - let _ = self - .docker - .remove_container( - &container.id, - Some(RemoveContainerOptions { - force: true, - ..Default::default() - }), - ) - .await; - - Ok(ExecutionResult { - stdout, - stderr, - exit_code, - timed_out, - }) - } - - /// Execute in a Rust environment - pub async fn execute_rust(&self, workspace: &Path, cmd: &[&str]) -> Result { - self.execute("rust:1.75-slim", cmd, Some(workspace), HashMap::new()) - .await - } - - /// Execute in a Python environment - pub async fn execute_python(&self, workspace: &Path, cmd: &[&str]) -> Result { - self.execute("python:3.11-slim", cmd, Some(workspace), HashMap::new()) - .await - } - - /// Execute in a Node.js environment - pub async fn execute_node(&self, workspace: &Path, cmd: &[&str]) -> Result { - self.execute("node:20-slim", cmd, Some(workspace), HashMap::new()) - .await - } -} - -impl Default for Sandbox { - fn default() -> Self { - Self::new().expect("Failed to create default sandbox") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - #[ignore] // Requires Docker daemon - async fn test_sandbox_rust_compile() { - let sandbox = Sandbox::new().unwrap(); - let temp_dir = TempDir::new().unwrap(); - - // Create a simple Rust project - std::fs::write( - temp_dir.path().join("main.rs"), - "fn main() { println!(\"Hello from sandbox!\"); }", - ) - .unwrap(); - - let result = sandbox - .execute_rust(temp_dir.path(), &["rustc", "main.rs"]) - .await - .unwrap(); - - assert_eq!(result.exit_code, 0); - assert!(!result.timed_out); - } -} diff --git a/crates/mcp/code-server/src/tools.rs b/crates/mcp/code-server/src/tools.rs deleted file mode 100644 index d91ad9e..0000000 --- a/crates/mcp/code-server/src/tools.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! Code execution tools using Docker sandboxing - -use crate::sandbox::Sandbox; -use async_trait::async_trait; -use owlen_core::Result; -use owlen_core::tools::{Tool, ToolResult}; -use serde_json::{Value, json}; -use std::path::PathBuf; - -/// Tool for compiling projects (Rust, Node.js, Python) -pub struct CompileProjectTool { - sandbox: Sandbox, -} - -impl Default for CompileProjectTool { - fn default() -> Self { - Self::new() - } -} - -impl CompileProjectTool { - pub fn new() -> Self { - Self { - sandbox: Sandbox::default(), - } - } -} - -#[async_trait] -impl Tool for CompileProjectTool { - fn name(&self) -> &'static str { - "compile_project" - } - - fn description(&self) -> &'static str { - "Compile a project (Rust, Node.js, Python). Detects project type automatically." - } - - fn schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "project_path": { - "type": "string", - "description": "Path to the project root" - }, - "project_type": { - "type": "string", - "enum": ["rust", "node", "python"], - "description": "Project type (auto-detected if not specified)" - } - }, - "required": ["project_path"] - }) - } - - async fn execute(&self, args: Value) -> Result { - let project_path = args - .get("project_path") - .and_then(|v| v.as_str()) - .ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?; - - let path = PathBuf::from(project_path); - if !path.exists() { - return Ok(ToolResult::error("Project path does not exist")); - } - - // Detect project type - let project_type = if let Some(pt) = args.get("project_type").and_then(|v| v.as_str()) { - pt.to_string() - } else if path.join("Cargo.toml").exists() { - "rust".to_string() - } else if path.join("package.json").exists() { - "node".to_string() - } else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() { - "python".to_string() - } else { - return Ok(ToolResult::error("Could not detect project type")); - }; - - // Execute compilation - let result = match project_type.as_str() { - "rust" => self.sandbox.execute_rust(&path, &["cargo", "build"]).await, - "node" => { - self.sandbox - .execute_node(&path, &["npm", "run", "build"]) - .await - } - "python" => { - // Python typically doesn't need compilation, but we can check syntax - self.sandbox - .execute_python(&path, &["python", "-m", "compileall", "."]) - .await - } - _ => return Ok(ToolResult::error("Unsupported project type")), - }; - - match result { - Ok(exec_result) => { - if exec_result.timed_out { - Ok(ToolResult::error("Compilation timed out")) - } else if exec_result.exit_code == 0 { - Ok(ToolResult::success(json!({ - "success": true, - "stdout": exec_result.stdout, - "stderr": exec_result.stderr, - "project_type": project_type - }))) - } else { - Ok(ToolResult::success(json!({ - "success": false, - "exit_code": exec_result.exit_code, - "stdout": exec_result.stdout, - "stderr": exec_result.stderr, - "project_type": project_type - }))) - } - } - Err(e) => Ok(ToolResult::error(&format!("Compilation failed: {}", e))), - } - } -} - -/// Tool for running test suites -pub struct RunTestsTool { - sandbox: Sandbox, -} - -impl Default for RunTestsTool { - fn default() -> Self { - Self::new() - } -} - -impl RunTestsTool { - pub fn new() -> Self { - Self { - sandbox: Sandbox::default(), - } - } -} - -#[async_trait] -impl Tool for RunTestsTool { - fn name(&self) -> &'static str { - "run_tests" - } - - fn description(&self) -> &'static str { - "Run tests for a project (Rust, Node.js, Python)" - } - - fn schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "project_path": { - "type": "string", - "description": "Path to the project root" - }, - "test_filter": { - "type": "string", - "description": "Optional test filter/pattern" - } - }, - "required": ["project_path"] - }) - } - - async fn execute(&self, args: Value) -> Result { - let project_path = args - .get("project_path") - .and_then(|v| v.as_str()) - .ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?; - - let path = PathBuf::from(project_path); - if !path.exists() { - return Ok(ToolResult::error("Project path does not exist")); - } - - let test_filter = args.get("test_filter").and_then(|v| v.as_str()); - - // Detect project type and run tests - let result = if path.join("Cargo.toml").exists() { - let cmd = if let Some(filter) = test_filter { - vec!["cargo", "test", filter] - } else { - vec!["cargo", "test"] - }; - self.sandbox.execute_rust(&path, &cmd).await - } else if path.join("package.json").exists() { - self.sandbox.execute_node(&path, &["npm", "test"]).await - } else if path.join("pytest.ini").exists() - || path.join("setup.py").exists() - || path.join("pyproject.toml").exists() - { - let cmd = if let Some(filter) = test_filter { - vec!["pytest", "-k", filter] - } else { - vec!["pytest"] - }; - self.sandbox.execute_python(&path, &cmd).await - } else { - return Ok(ToolResult::error("Could not detect test framework")); - }; - - match result { - Ok(exec_result) => Ok(ToolResult::success(json!({ - "success": exec_result.exit_code == 0 && !exec_result.timed_out, - "exit_code": exec_result.exit_code, - "stdout": exec_result.stdout, - "stderr": exec_result.stderr, - "timed_out": exec_result.timed_out - }))), - Err(e) => Ok(ToolResult::error(&format!("Tests failed to run: {}", e))), - } - } -} - -/// Tool for formatting code -pub struct FormatCodeTool { - sandbox: Sandbox, -} - -impl Default for FormatCodeTool { - fn default() -> Self { - Self::new() - } -} - -impl FormatCodeTool { - pub fn new() -> Self { - Self { - sandbox: Sandbox::default(), - } - } -} - -#[async_trait] -impl Tool for FormatCodeTool { - fn name(&self) -> &'static str { - "format_code" - } - - fn description(&self) -> &'static str { - "Format code using project-appropriate formatter (rustfmt, prettier, black)" - } - - fn schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "project_path": { - "type": "string", - "description": "Path to the project root" - }, - "check_only": { - "type": "boolean", - "description": "Only check formatting without modifying files", - "default": false - } - }, - "required": ["project_path"] - }) - } - - async fn execute(&self, args: Value) -> Result { - let project_path = args - .get("project_path") - .and_then(|v| v.as_str()) - .ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?; - - let path = PathBuf::from(project_path); - if !path.exists() { - return Ok(ToolResult::error("Project path does not exist")); - } - - let check_only = args - .get("check_only") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - // Detect project type and run formatter - let result = if path.join("Cargo.toml").exists() { - let cmd = if check_only { - vec!["cargo", "fmt", "--", "--check"] - } else { - vec!["cargo", "fmt"] - }; - self.sandbox.execute_rust(&path, &cmd).await - } else if path.join("package.json").exists() { - let cmd = if check_only { - vec!["npx", "prettier", "--check", "."] - } else { - vec!["npx", "prettier", "--write", "."] - }; - self.sandbox.execute_node(&path, &cmd).await - } else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() { - let cmd = if check_only { - vec!["black", "--check", "."] - } else { - vec!["black", "."] - }; - self.sandbox.execute_python(&path, &cmd).await - } else { - return Ok(ToolResult::error("Could not detect project type")); - }; - - match result { - Ok(exec_result) => Ok(ToolResult::success(json!({ - "success": exec_result.exit_code == 0, - "formatted": !check_only && exec_result.exit_code == 0, - "stdout": exec_result.stdout, - "stderr": exec_result.stderr - }))), - Err(e) => Ok(ToolResult::error(&format!("Formatting failed: {}", e))), - } - } -} - -/// Tool for linting code -pub struct LintCodeTool { - sandbox: Sandbox, -} - -impl Default for LintCodeTool { - fn default() -> Self { - Self::new() - } -} - -impl LintCodeTool { - pub fn new() -> Self { - Self { - sandbox: Sandbox::default(), - } - } -} - -#[async_trait] -impl Tool for LintCodeTool { - fn name(&self) -> &'static str { - "lint_code" - } - - fn description(&self) -> &'static str { - "Lint code using project-appropriate linter (clippy, eslint, pylint)" - } - - fn schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "project_path": { - "type": "string", - "description": "Path to the project root" - }, - "fix": { - "type": "boolean", - "description": "Automatically fix issues if possible", - "default": false - } - }, - "required": ["project_path"] - }) - } - - async fn execute(&self, args: Value) -> Result { - let project_path = args - .get("project_path") - .and_then(|v| v.as_str()) - .ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?; - - let path = PathBuf::from(project_path); - if !path.exists() { - return Ok(ToolResult::error("Project path does not exist")); - } - - let fix = args.get("fix").and_then(|v| v.as_bool()).unwrap_or(false); - - // Detect project type and run linter - let result = if path.join("Cargo.toml").exists() { - let cmd = if fix { - vec!["cargo", "clippy", "--fix", "--allow-dirty"] - } else { - vec!["cargo", "clippy"] - }; - self.sandbox.execute_rust(&path, &cmd).await - } else if path.join("package.json").exists() { - let cmd = if fix { - vec!["npx", "eslint", ".", "--fix"] - } else { - vec!["npx", "eslint", "."] - }; - self.sandbox.execute_node(&path, &cmd).await - } else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() { - // pylint doesn't have auto-fix - self.sandbox.execute_python(&path, &["pylint", "."]).await - } else { - return Ok(ToolResult::error("Could not detect project type")); - }; - - match result { - Ok(exec_result) => { - let issues_found = exec_result.exit_code != 0; - Ok(ToolResult::success(json!({ - "success": true, - "issues_found": issues_found, - "exit_code": exec_result.exit_code, - "stdout": exec_result.stdout, - "stderr": exec_result.stderr - }))) - } - Err(e) => Ok(ToolResult::error(&format!("Linting failed: {}", e))), - } - } -} diff --git a/crates/mcp/llm-server/Cargo.toml b/crates/mcp/llm-server/Cargo.toml deleted file mode 100644 index d788153..0000000 --- a/crates/mcp/llm-server/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "owlen-mcp-llm-server" -version = "0.1.0" -edition.workspace = true - -[dependencies] -owlen-core = { path = "../../owlen-core" } -tokio = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } -tokio-stream = { workspace = true } - -[[bin]] -name = "owlen-mcp-llm-server" -path = "src/main.rs" diff --git a/crates/mcp/llm-server/src/main.rs b/crates/mcp/llm-server/src/main.rs deleted file mode 100644 index 14a0dba..0000000 --- a/crates/mcp/llm-server/src/main.rs +++ /dev/null @@ -1,598 +0,0 @@ -#![allow( - unused_imports, - unused_variables, - dead_code, - clippy::unnecessary_cast, - clippy::manual_flatten, - clippy::empty_line_after_outer_attr -)] - -use owlen_core::Provider; -use owlen_core::ProviderConfig; -use owlen_core::config::{Config as OwlenConfig, ensure_provider_config}; -use owlen_core::mcp::protocol::{ - ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError, - RpcErrorResponse, RpcNotification, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, - methods, -}; -use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse}; -use owlen_core::providers::OllamaProvider; -use owlen_core::types::{ChatParameters, ChatRequest, Message}; -use serde::Deserialize; -use serde_json::{Value, json}; -use std::collections::HashMap; -use std::env; -use std::sync::Arc; -use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt}; -use tokio_stream::StreamExt; - -// Suppress warnings are handled by the crate-level attribute at the top. - -/// Arguments for the generate_text tool -#[derive(Debug, Deserialize)] -struct GenerateTextArgs { - messages: Vec, - temperature: Option, - max_tokens: Option, - model: String, - stream: bool, -} - -/// Simple tool descriptor for generate_text -fn generate_text_descriptor() -> McpToolDescriptor { - McpToolDescriptor { - name: "generate_text".to_string(), - description: "Generate text using Ollama LLM. Each message must have 'role' (user/assistant/system) and 'content' (string) fields.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "messages": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "enum": ["user", "assistant", "system"], - "description": "The role of the message sender" - }, - "content": { - "type": "string", - "description": "The message content" - } - }, - "required": ["role", "content"] - }, - "description": "Array of message objects with role and content" - }, - "temperature": {"type": ["number", "null"], "description": "Sampling temperature (0.0-2.0)"}, - "max_tokens": {"type": ["integer", "null"], "description": "Maximum tokens to generate"}, - "model": {"type": "string", "description": "Model name (e.g., llama3.2:latest)"}, - "stream": {"type": "boolean", "description": "Whether to stream the response"} - }, - "required": ["messages", "model", "stream"] - }), - requires_network: true, - requires_filesystem: vec![], - } -} - -/// Tool descriptor for resources/get (read file) -fn resources_get_descriptor() -> McpToolDescriptor { - McpToolDescriptor { - name: "resources_get".to_string(), - description: "Read and return the TEXT CONTENTS of a single FILE. Use this to read the contents of code files, config files, or text documents. Do NOT use for directories.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "path": {"type": "string", "description": "Path to the FILE (not directory) to read"} - }, - "required": ["path"] - }), - requires_network: false, - requires_filesystem: vec!["read".to_string()], - } -} - -/// Tool descriptor for resources/list (list directory) -fn resources_list_descriptor() -> McpToolDescriptor { - McpToolDescriptor { - name: "resources_list".to_string(), - description: "List the NAMES of all files and directories in a directory. Use this to see what files exist in a folder, or to list directory contents. Returns an array of file/directory names.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "path": {"type": "string", "description": "Path to the DIRECTORY to list (use '.' for current directory)"} - } - }), - requires_network: false, - requires_filesystem: vec!["read".to_string()], - } -} - -fn provider_from_config() -> Result, RpcError> { - let mut config = OwlenConfig::load(None).unwrap_or_default(); - let requested_name = - env::var("OWLEN_PROVIDER").unwrap_or_else(|_| config.general.default_provider.clone()); - let provider_key = canonical_provider_name(&requested_name); - if config.provider(&provider_key).is_none() { - ensure_provider_config(&mut config, &provider_key); - } - let provider_cfg: ProviderConfig = - config.provider(&provider_key).cloned().ok_or_else(|| { - RpcError::internal_error(format!( - "Provider '{provider_key}' not found in configuration" - )) - })?; - - match provider_cfg.provider_type.as_str() { - "ollama" | "ollama_cloud" => { - let provider = - OllamaProvider::from_config(&provider_key, &provider_cfg, Some(&config.general)) - .map_err(|e| { - RpcError::internal_error(format!( - "Failed to init Ollama provider from config: {e}" - )) - })?; - Ok(Arc::new(provider) as Arc) - } - other => Err(RpcError::internal_error(format!( - "Unsupported provider type '{other}' for MCP LLM server" - ))), - } -} - -fn create_provider() -> Result, RpcError> { - if let Ok(url) = env::var("OLLAMA_URL") { - let provider = OllamaProvider::new(&url).map_err(|e| { - RpcError::internal_error(format!("Failed to init Ollama provider: {e}")) - })?; - return Ok(Arc::new(provider) as Arc); - } - - provider_from_config() -} - -fn canonical_provider_name(name: &str) -> String { - let normalized = name.trim().to_ascii_lowercase().replace('-', "_"); - match normalized.as_str() { - "" => "ollama_local".to_string(), - "ollama" | "ollama_local" => "ollama_local".to_string(), - "ollama_cloud" => "ollama_cloud".to_string(), - other => other.to_string(), - } -} - -async fn handle_generate_text(args: GenerateTextArgs) -> Result { - let provider = create_provider()?; - - let parameters = ChatParameters { - temperature: args.temperature, - max_tokens: args.max_tokens.map(|v| v as u32), - stream: args.stream, - extra: HashMap::new(), - }; - - let request = ChatRequest { - model: args.model, - messages: args.messages, - parameters, - tools: None, - }; - - // Use streaming API and collect output - let mut stream = provider - .stream_prompt(request) - .await - .map_err(|e| RpcError::internal_error(format!("Chat request failed: {}", e)))?; - let mut content = String::new(); - while let Some(chunk) = stream.next().await { - match chunk { - Ok(resp) => { - content.push_str(&resp.message.content); - if resp.is_final { - break; - } - } - Err(e) => { - return Err(RpcError::internal_error(format!("Stream error: {}", e))); - } - } - } - Ok(content) -} - -async fn handle_request(req: &RpcRequest) -> Result { - match req.method.as_str() { - methods::INITIALIZE => { - let params = req - .params - .as_ref() - .ok_or_else(|| RpcError::invalid_params("Missing params for initialize"))?; - let init: InitializeParams = serde_json::from_value(params.clone()) - .map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?; - if !init.protocol_version.eq(PROTOCOL_VERSION) { - return Err(RpcError::new( - ErrorCode::INVALID_REQUEST, - format!( - "Incompatible protocol version. Client: {}, Server: {}", - init.protocol_version, PROTOCOL_VERSION - ), - )); - } - let result = InitializeResult { - protocol_version: PROTOCOL_VERSION.to_string(), - server_info: ServerInfo { - name: "owlen-mcp-llm-server".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: ServerCapabilities { - supports_tools: Some(true), - supports_resources: Some(false), - supports_streaming: Some(true), - }, - }; - serde_json::to_value(result).map_err(|e| { - RpcError::internal_error(format!("Failed to serialize init result: {}", e)) - }) - } - methods::TOOLS_LIST => { - let tools = vec![ - generate_text_descriptor(), - resources_get_descriptor(), - resources_list_descriptor(), - ]; - Ok(json!(tools)) - } - // New method to list available Ollama models via the provider. - methods::MODELS_LIST => { - let provider = create_provider()?; - let models = provider - .list_models() - .await - .map_err(|e| RpcError::internal_error(format!("Failed to list models: {}", e)))?; - serde_json::to_value(models).map_err(|e| { - RpcError::internal_error(format!("Failed to serialize model list: {}", e)) - }) - } - methods::TOOLS_CALL => { - // For streaming we will send incremental notifications directly from here. - // The caller (main loop) will handle writing the final response. - Err(RpcError::internal_error( - "TOOLS_CALL should be handled in main loop for streaming", - )) - } - _ => Err(RpcError::method_not_found(&req.method)), - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let root = env::current_dir()?; // not used but kept for parity - let mut stdin = io::BufReader::new(io::stdin()); - let mut stdout = io::stdout(); - loop { - let mut line = String::new(); - match stdin.read_line(&mut line).await { - Ok(0) => break, - Ok(_) => { - let req: RpcRequest = match serde_json::from_str(&line) { - Ok(r) => r, - Err(e) => { - let err = RpcErrorResponse::new( - RequestId::Number(0), - RpcError::parse_error(format!("Parse error: {}", e)), - ); - let s = serde_json::to_string(&err)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - let id = req.id.clone(); - // Streaming tool calls (generate_text) are handled specially to emit incremental notifications. - if req.method == methods::TOOLS_CALL { - // Parse the tool call - let params = match &req.params { - Some(p) => p, - None => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::invalid_params("Missing params for tool call"), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - let call: McpToolCall = match serde_json::from_value(params.clone()) { - Ok(c) => c, - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::invalid_params(format!("Invalid tool call: {}", e)), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - // Dispatch based on the requested tool name. - // Handle resources tools manually. - if call.name.starts_with("resources_get") { - let path = call - .arguments - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or(""); - match std::fs::read_to_string(path) { - Ok(content) => { - let response = McpToolResponse { - name: call.name, - success: true, - output: json!(content), - metadata: HashMap::new(), - duration_ms: 0, - }; - let payload = match serde_json::to_value(&response) { - Ok(value) => value, - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::internal_error(format!( - "Failed to serialize resource response: {}", - e - )), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - let final_resp = RpcResponse::new(id.clone(), payload); - let s = serde_json::to_string(&final_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::internal_error(format!("Failed to read file: {}", e)), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - } - } - if call.name.starts_with("resources_list") { - let path = call - .arguments - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("."); - match std::fs::read_dir(path) { - Ok(entries) => { - let mut names = Vec::new(); - for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() { - names.push(name.to_string()); - } - } - let response = McpToolResponse { - name: call.name, - success: true, - output: json!(names), - metadata: HashMap::new(), - duration_ms: 0, - }; - let payload = match serde_json::to_value(&response) { - Ok(value) => value, - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::internal_error(format!( - "Failed to serialize directory listing: {}", - e - )), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - let final_resp = RpcResponse::new(id.clone(), payload); - let s = serde_json::to_string(&final_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::internal_error(format!("Failed to list dir: {}", e)), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - } - } - // Expect generate_text tool for the remaining path. - if call.name != "generate_text" { - let err_resp = - RpcErrorResponse::new(id.clone(), RpcError::tool_not_found(&call.name)); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - let args: GenerateTextArgs = - match serde_json::from_value(call.arguments.clone()) { - Ok(a) => a, - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::invalid_params(format!("Invalid arguments: {}", e)), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - - // Initialize provider and start streaming - let provider = match create_provider() { - Ok(p) => p, - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::internal_error(format!( - "Failed to initialize provider: {:?}", - e - )), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - let parameters = ChatParameters { - temperature: args.temperature, - max_tokens: args.max_tokens.map(|v| v as u32), - stream: true, - extra: HashMap::new(), - }; - let request = ChatRequest { - model: args.model, - messages: args.messages, - parameters, - tools: None, - }; - let mut stream = match provider.stream_prompt(request).await { - Ok(s) => s, - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::internal_error(format!("Chat request failed: {}", e)), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - // Accumulate full content while sending incremental progress notifications - let mut final_content = String::new(); - while let Some(chunk) = stream.next().await { - match chunk { - Ok(resp) => { - // Append chunk to the final content buffer - final_content.push_str(&resp.message.content); - // Emit a progress notification for the UI - let notif = RpcNotification::new( - "tools/call/progress", - Some(json!({ "content": resp.message.content })), - ); - let s = serde_json::to_string(¬if)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - if resp.is_final { - break; - } - } - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::internal_error(format!("Stream error: {}", e)), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - break; - } - } - } - // After streaming, send the final tool response containing the full content - let final_output = final_content.clone(); - let response = McpToolResponse { - name: call.name, - success: true, - output: json!(final_output), - metadata: HashMap::new(), - duration_ms: 0, - }; - let payload = match serde_json::to_value(&response) { - Ok(value) => value, - Err(e) => { - let err_resp = RpcErrorResponse::new( - id.clone(), - RpcError::internal_error(format!( - "Failed to serialize final streaming response: {}", - e - )), - ); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - let final_resp = RpcResponse::new(id.clone(), payload); - let s = serde_json::to_string(&final_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - // Non‑streaming requests are handled by the generic handler - match handle_request(&req).await { - Ok(res) => { - let resp = RpcResponse::new(id, res); - let s = serde_json::to_string(&resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - Err(err) => { - let err_resp = RpcErrorResponse::new(id, err); - let s = serde_json::to_string(&err_resp)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - } - } - Err(e) => { - eprintln!("Read error: {}", e); - break; - } - } - } - Ok(()) -} diff --git a/crates/mcp/prompt-server/Cargo.toml b/crates/mcp/prompt-server/Cargo.toml deleted file mode 100644 index 7f6ef85..0000000 --- a/crates/mcp/prompt-server/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "owlen-mcp-prompt-server" -version = "0.1.0" -edition.workspace = true -description = "MCP server that renders prompt templates (YAML) for Owlen" -license = "AGPL-3.0" - -[dependencies] -owlen-core = { path = "../../owlen-core" } -serde = { workspace = true } -serde_json = { workspace = true } -serde_yaml = { workspace = true } -tokio = { workspace = true } -anyhow = { workspace = true } -handlebars = { workspace = true } -dirs = { workspace = true } -futures = { workspace = true } - -[lib] -name = "owlen_mcp_prompt_server" -path = "src/lib.rs" diff --git a/crates/mcp/prompt-server/src/lib.rs b/crates/mcp/prompt-server/src/lib.rs deleted file mode 100644 index 90b89db..0000000 --- a/crates/mcp/prompt-server/src/lib.rs +++ /dev/null @@ -1,415 +0,0 @@ -//! MCP server for rendering prompt templates with YAML storage and Handlebars rendering. -//! -//! Templates are stored in `~/.config/owlen/prompts/` as YAML files. -//! Provides full Handlebars templating support for dynamic prompt generation. - -use anyhow::{Context, Result}; -use handlebars::Handlebars; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::sync::RwLock; - -use owlen_core::mcp::protocol::{ - ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError, - RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, methods, -}; -use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse}; -use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt}; - -/// Prompt template definition -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PromptTemplate { - /// Template name - pub name: String, - /// Template version - pub version: String, - /// Optional mode restriction - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, - /// Handlebars template content - pub template: String, - /// Template description - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// Prompt server managing templates -pub struct PromptServer { - templates: Arc>>, - handlebars: Handlebars<'static>, - templates_dir: PathBuf, -} - -impl PromptServer { - /// Create a new prompt server - pub fn new() -> Result { - let templates_dir = Self::get_templates_dir()?; - - // Create templates directory if it doesn't exist - if !templates_dir.exists() { - fs::create_dir_all(&templates_dir)?; - Self::create_default_templates(&templates_dir)?; - } - - let mut server = Self { - templates: Arc::new(RwLock::new(HashMap::new())), - handlebars: Handlebars::new(), - templates_dir, - }; - - // Load all templates - server.load_templates()?; - - Ok(server) - } - - /// Get the templates directory path - fn get_templates_dir() -> Result { - let config_dir = dirs::config_dir().context("Could not determine config directory")?; - Ok(config_dir.join("owlen").join("prompts")) - } - - /// Create default template examples - fn create_default_templates(dir: &Path) -> Result<()> { - let chat_mode_system = PromptTemplate { - name: "chat_mode_system".to_string(), - version: "1.0".to_string(), - mode: Some("chat".to_string()), - description: Some("System prompt for chat mode".to_string()), - template: r#"You are Owlen, a helpful AI assistant. You have access to these tools: -{{#each tools}} -- {{name}}: {{description}} -{{/each}} - -Use the ReAct pattern: -THOUGHT: Your reasoning -ACTION: tool_name -ACTION_INPUT: {"param": "value"} - -When you have enough information: -FINAL_ANSWER: Your response"# - .to_string(), - }; - - let code_mode_system = PromptTemplate { - name: "code_mode_system".to_string(), - version: "1.0".to_string(), - mode: Some("code".to_string()), - description: Some("System prompt for code mode".to_string()), - template: r#"You are Owlen in code mode, with full development capabilities. You have access to: -{{#each tools}} -- {{name}}: {{description}} -{{/each}} - -Use the ReAct pattern to solve coding tasks: -THOUGHT: Analyze what needs to be done -ACTION: tool_name (compile_project, run_tests, format_code, lint_code, etc.) -ACTION_INPUT: {"param": "value"} - -Continue iterating until the task is complete, then provide: -FINAL_ANSWER: Summary of what was done"# - .to_string(), - }; - - // Save templates - let chat_path = dir.join("chat_mode_system.yaml"); - let code_path = dir.join("code_mode_system.yaml"); - - fs::write(chat_path, serde_yaml::to_string(&chat_mode_system)?)?; - fs::write(code_path, serde_yaml::to_string(&code_mode_system)?)?; - - Ok(()) - } - - /// Load all templates from the templates directory - fn load_templates(&mut self) -> Result<()> { - let entries = fs::read_dir(&self.templates_dir)?; - - for entry in entries { - let entry = entry?; - let path = entry.path(); - - if path.extension().and_then(|s| s.to_str()) == Some("yaml") - || path.extension().and_then(|s| s.to_str()) == Some("yml") - { - match self.load_template(&path) { - Ok(template) => { - // Register with Handlebars - if let Err(e) = self - .handlebars - .register_template_string(&template.name, &template.template) - { - eprintln!( - "Warning: Failed to register template {}: {}", - template.name, e - ); - } else { - let mut templates = self.templates.blocking_write(); - templates.insert(template.name.clone(), template); - } - } - Err(e) => { - eprintln!("Warning: Failed to load template {:?}: {}", path, e); - } - } - } - } - - Ok(()) - } - - /// Load a single template from file - fn load_template(&self, path: &Path) -> Result { - let content = fs::read_to_string(path)?; - let template: PromptTemplate = serde_yaml::from_str(&content)?; - Ok(template) - } - - /// Get a template by name - pub async fn get_template(&self, name: &str) -> Option { - let templates = self.templates.read().await; - templates.get(name).cloned() - } - - /// List all available templates - pub async fn list_templates(&self) -> Vec { - let templates = self.templates.read().await; - templates.keys().cloned().collect() - } - - /// Render a template with given variables - pub fn render_template(&self, name: &str, vars: &Value) -> Result { - self.handlebars - .render(name, vars) - .context("Failed to render template") - } - - /// Reload all templates from disk - pub async fn reload_templates(&mut self) -> Result<()> { - { - let mut templates = self.templates.write().await; - templates.clear(); - } - self.handlebars = Handlebars::new(); - self.load_templates() - } -} - -#[allow(dead_code)] -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let mut stdin = io::BufReader::new(io::stdin()); - let mut stdout = io::stdout(); - - let server = Arc::new(tokio::sync::Mutex::new(PromptServer::new()?)); - - loop { - let mut line = String::new(); - match stdin.read_line(&mut line).await { - Ok(0) => break, // EOF - Ok(_) => { - let req: RpcRequest = match serde_json::from_str(&line) { - Ok(r) => r, - Err(e) => { - let err = RpcErrorResponse::new( - RequestId::Number(0), - RpcError::parse_error(format!("Parse error: {}", e)), - ); - let s = serde_json::to_string(&err)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - - let resp = handle_request(req.clone(), server.clone()).await; - match resp { - Ok(r) => { - let s = serde_json::to_string(&r)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - Err(e) => { - let err = RpcErrorResponse::new(req.id.clone(), e); - let s = serde_json::to_string(&err)?; - stdout.write_all(s.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - } - } - Err(e) => { - eprintln!("Error reading stdin: {}", e); - break; - } - } - } - Ok(()) -} - -#[allow(dead_code)] -async fn handle_request( - req: RpcRequest, - server: Arc>, -) -> Result { - match req.method.as_str() { - methods::INITIALIZE => { - let params: InitializeParams = - serde_json::from_value(req.params.unwrap_or_else(|| json!({}))) - .map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?; - if !params.protocol_version.eq(PROTOCOL_VERSION) { - return Err(RpcError::new( - ErrorCode::INVALID_REQUEST, - format!( - "Incompatible protocol version. Client: {}, Server: {}", - params.protocol_version, PROTOCOL_VERSION - ), - )); - } - let result = InitializeResult { - protocol_version: PROTOCOL_VERSION.to_string(), - server_info: ServerInfo { - name: "owlen-mcp-prompt-server".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: ServerCapabilities { - supports_tools: Some(true), - supports_resources: Some(false), - supports_streaming: Some(false), - }, - }; - let payload = serde_json::to_value(result).map_err(|e| { - RpcError::internal_error(format!("Failed to serialize initialize result: {}", e)) - })?; - Ok(RpcResponse::new(req.id, payload)) - } - methods::TOOLS_LIST => { - let tools = vec![ - McpToolDescriptor { - name: "get_prompt".to_string(), - description: "Retrieve a prompt template by name".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "name": {"type": "string", "description": "Template name"} - }, - "required": ["name"] - }), - requires_network: false, - requires_filesystem: vec![], - }, - McpToolDescriptor { - name: "render_prompt".to_string(), - description: "Render a prompt template with Handlebars variables".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "name": {"type": "string", "description": "Template name"}, - "vars": {"type": "object", "description": "Variables for Handlebars rendering"} - }, - "required": ["name"] - }), - requires_network: false, - requires_filesystem: vec![], - }, - McpToolDescriptor { - name: "list_prompts".to_string(), - description: "List all available prompt templates".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - requires_network: false, - requires_filesystem: vec![], - }, - McpToolDescriptor { - name: "reload_prompts".to_string(), - description: "Reload all prompts from disk".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - requires_network: false, - requires_filesystem: vec![], - }, - ]; - Ok(RpcResponse::new(req.id, json!(tools))) - } - methods::TOOLS_CALL => { - let call: McpToolCall = serde_json::from_value(req.params.unwrap_or_else(|| json!({}))) - .map_err(|e| RpcError::invalid_params(format!("Invalid tool call: {}", e)))?; - - let result = match call.name.as_str() { - "get_prompt" => { - let name = call - .arguments - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| RpcError::invalid_params("Missing 'name' parameter"))?; - - let srv = server.lock().await; - match srv.get_template(name).await { - Some(template) => match serde_json::to_value(template) { - Ok(serialized) => { - json!({"success": true, "template": serialized}) - } - Err(e) => { - return Err(RpcError::internal_error(format!( - "Failed to serialize template '{}': {}", - name, e - ))); - } - }, - None => json!({"success": false, "error": "Template not found"}), - } - } - "render_prompt" => { - let name = call - .arguments - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| RpcError::invalid_params("Missing 'name' parameter"))?; - - let default_vars = json!({}); - let vars = call.arguments.get("vars").unwrap_or(&default_vars); - - let srv = server.lock().await; - match srv.render_template(name, vars) { - Ok(rendered) => json!({"success": true, "rendered": rendered}), - Err(e) => json!({"success": false, "error": e.to_string()}), - } - } - "list_prompts" => { - let srv = server.lock().await; - let templates = srv.list_templates().await; - json!({"success": true, "templates": templates}) - } - "reload_prompts" => { - let mut srv = server.lock().await; - match srv.reload_templates().await { - Ok(_) => json!({"success": true, "message": "Prompts reloaded"}), - Err(e) => json!({"success": false, "error": e.to_string()}), - } - } - _ => return Err(RpcError::method_not_found(&call.name)), - }; - - let resp = McpToolResponse { - name: call.name, - success: result - .get("success") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - output: result, - metadata: HashMap::new(), - duration_ms: 0, - }; - - let payload = serde_json::to_value(resp).map_err(|e| { - RpcError::internal_error(format!("Failed to serialize tool response: {}", e)) - })?; - Ok(RpcResponse::new(req.id, payload)) - } - _ => Err(RpcError::method_not_found(&req.method)), - } -} diff --git a/crates/mcp/prompt-server/templates/example.yaml b/crates/mcp/prompt-server/templates/example.yaml deleted file mode 100644 index ad12c7c..0000000 --- a/crates/mcp/prompt-server/templates/example.yaml +++ /dev/null @@ -1,3 +0,0 @@ -prompt: | - Hello {{name}}! - Your role is: {{role}}. diff --git a/crates/mcp/server/Cargo.toml b/crates/mcp/server/Cargo.toml deleted file mode 100644 index dfb0e35..0000000 --- a/crates/mcp/server/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "owlen-mcp-server" -version = "0.1.0" -edition.workspace = true - -[dependencies] -tokio = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } -path-clean = "1.0" -owlen-core = { path = "../../owlen-core" } diff --git a/crates/mcp/server/src/main.rs b/crates/mcp/server/src/main.rs deleted file mode 100644 index 5d8142a..0000000 --- a/crates/mcp/server/src/main.rs +++ /dev/null @@ -1,246 +0,0 @@ -use owlen_core::mcp::protocol::{ - ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError, - RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, is_compatible, -}; -use path_clean::PathClean; -use serde::Deserialize; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt}; - -#[derive(Deserialize)] -struct FileArgs { - path: String, -} - -#[derive(Deserialize)] -struct WriteArgs { - path: String, - content: String, -} - -async fn handle_request(req: &RpcRequest, root: &Path) -> Result { - match req.method.as_str() { - "initialize" => { - let params = req - .params - .as_ref() - .ok_or_else(|| RpcError::invalid_params("Missing params for initialize"))?; - - let init_params: InitializeParams = - serde_json::from_value(params.clone()).map_err(|e| { - RpcError::invalid_params(format!("Invalid initialize params: {}", e)) - })?; - - // Check protocol version compatibility - if !is_compatible(&init_params.protocol_version, PROTOCOL_VERSION) { - return Err(RpcError::new( - ErrorCode::INVALID_REQUEST, - format!( - "Incompatible protocol version. Client: {}, Server: {}", - init_params.protocol_version, PROTOCOL_VERSION - ), - )); - } - - // Build initialization result - let result = InitializeResult { - protocol_version: PROTOCOL_VERSION.to_string(), - server_info: ServerInfo { - name: "owlen-mcp-server".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: ServerCapabilities { - supports_tools: Some(false), - supports_resources: Some(true), // Supports read, write, delete - supports_streaming: Some(false), - }, - }; - - Ok(serde_json::to_value(result).map_err(|e| { - RpcError::internal_error(format!("Failed to serialize result: {}", e)) - })?) - } - "resources_list" => { - let params = req - .params - .as_ref() - .ok_or_else(|| RpcError::invalid_params("Missing params"))?; - let args: FileArgs = serde_json::from_value(params.clone()) - .map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?; - resources_list(&args.path, root).await - } - "resources_get" => { - let params = req - .params - .as_ref() - .ok_or_else(|| RpcError::invalid_params("Missing params"))?; - let args: FileArgs = serde_json::from_value(params.clone()) - .map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?; - resources_get(&args.path, root).await - } - "resources_write" => { - let params = req - .params - .as_ref() - .ok_or_else(|| RpcError::invalid_params("Missing params"))?; - let args: WriteArgs = serde_json::from_value(params.clone()) - .map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?; - resources_write(&args.path, &args.content, root).await - } - "resources_delete" => { - let params = req - .params - .as_ref() - .ok_or_else(|| RpcError::invalid_params("Missing params"))?; - let args: FileArgs = serde_json::from_value(params.clone()) - .map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?; - resources_delete(&args.path, root).await - } - _ => Err(RpcError::method_not_found(&req.method)), - } -} - -fn sanitize_path(path: &str, root: &Path) -> Result { - let path = Path::new(path); - let path = if path.is_absolute() { - path.strip_prefix("/") - .map_err(|_| RpcError::invalid_params("Invalid path"))? - .to_path_buf() - } else { - path.to_path_buf() - }; - - let full_path = root.join(path).clean(); - - if !full_path.starts_with(root) { - return Err(RpcError::path_traversal()); - } - - Ok(full_path) -} - -async fn resources_list(path: &str, root: &Path) -> Result { - let full_path = sanitize_path(path, root)?; - - let entries = fs::read_dir(full_path).map_err(|e| { - RpcError::new( - ErrorCode::RESOURCE_NOT_FOUND, - format!("Failed to read directory: {}", e), - ) - })?; - - let mut result = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| { - RpcError::internal_error(format!("Failed to read directory entry: {}", e)) - })?; - result.push(entry.file_name().to_string_lossy().to_string()); - } - - Ok(serde_json::json!(result)) -} - -async fn resources_get(path: &str, root: &Path) -> Result { - let full_path = sanitize_path(path, root)?; - - let content = fs::read_to_string(full_path).map_err(|e| { - RpcError::new( - ErrorCode::RESOURCE_NOT_FOUND, - format!("Failed to read file: {}", e), - ) - })?; - - Ok(serde_json::json!(content)) -} - -async fn resources_write( - path: &str, - content: &str, - root: &Path, -) -> Result { - let full_path = sanitize_path(path, root)?; - // Ensure parent directory exists - if let Some(parent) = full_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - RpcError::internal_error(format!("Failed to create parent directories: {}", e)) - })?; - } - std::fs::write(full_path, content) - .map_err(|e| RpcError::internal_error(format!("Failed to write file: {}", e)))?; - Ok(serde_json::json!(null)) -} - -async fn resources_delete(path: &str, root: &Path) -> Result { - let full_path = sanitize_path(path, root)?; - if full_path.is_file() { - std::fs::remove_file(full_path) - .map_err(|e| RpcError::internal_error(format!("Failed to delete file: {}", e)))?; - Ok(serde_json::json!(null)) - } else { - Err(RpcError::new( - ErrorCode::RESOURCE_NOT_FOUND, - "Path does not refer to a file", - )) - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let root = env::current_dir()?; - let mut stdin = io::BufReader::new(io::stdin()); - let mut stdout = io::stdout(); - - loop { - let mut line = String::new(); - match stdin.read_line(&mut line).await { - Ok(0) => { - // EOF - break; - } - Ok(_) => { - let req: RpcRequest = match serde_json::from_str(&line) { - Ok(req) => req, - Err(e) => { - let err_resp = RpcErrorResponse::new( - RequestId::Number(0), - RpcError::parse_error(format!("Parse error: {}", e)), - ); - let resp_str = serde_json::to_string(&err_resp)?; - stdout.write_all(resp_str.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - continue; - } - }; - - let request_id = req.id.clone(); - - match handle_request(&req, &root).await { - Ok(result) => { - let resp = RpcResponse::new(request_id, result); - let resp_str = serde_json::to_string(&resp)?; - stdout.write_all(resp_str.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - Err(error) => { - let err_resp = RpcErrorResponse::new(request_id, error); - let resp_str = serde_json::to_string(&err_resp)?; - stdout.write_all(resp_str.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - } - } - Err(e) => { - // Handle read error - eprintln!("Error reading from stdin: {}", e); - break; - } - } - } - - Ok(()) -} diff --git a/crates/owlen-cli/Cargo.toml b/crates/owlen-cli/Cargo.toml deleted file mode 100644 index 6741b06..0000000 --- a/crates/owlen-cli/Cargo.toml +++ /dev/null @@ -1,63 +0,0 @@ -[package] -name = "owlen-cli" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Command-line interface for OWLEN LLM client" - -[features] -default = ["chat-client"] -chat-client = ["owlen-tui"] - -[[bin]] -name = "owlen" -path = "src/main.rs" -required-features = ["chat-client"] - -[[bin]] -name = "owlen-code" -path = "src/code_main.rs" -required-features = ["chat-client"] - -[[bin]] -name = "owlen-agent" -path = "src/agent_main.rs" -required-features = ["chat-client"] - -[dependencies] -owlen-core = { path = "../owlen-core" } -owlen-providers = { path = "../owlen-providers" } -# Optional TUI dependency, enabled by the "chat-client" feature. -owlen-tui = { path = "../owlen-tui", optional = true } -log = { workspace = true } -async-trait = { workspace = true } -futures = { workspace = true } - -# CLI framework -clap = { workspace = true, features = ["derive"] } - -# Async runtime -tokio = { workspace = true } -tokio-util = { workspace = true } - -# TUI framework -ratatui = { workspace = true } -crossterm = { workspace = true } - -# Utilities -anyhow = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -regex = { workspace = true } -thiserror = { workspace = true } -dirs = { workspace = true } -base64 = { workspace = true } -mime_guess = { workspace = true } -image = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } -tokio-test = { workspace = true } diff --git a/crates/owlen-cli/README.md b/crates/owlen-cli/README.md deleted file mode 100644 index 118e829..0000000 --- a/crates/owlen-cli/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Owlen CLI - -This crate is the command-line entry point for the Owlen application. - -It is responsible for: - -- Parsing command-line arguments. -- Loading the configuration. -- Initializing the providers. -- Starting the `owlen-tui` application. - -There are two binaries: - -- `owlen`: The main chat application. -- `owlen-code`: A specialized version for code-related tasks. diff --git a/crates/owlen-cli/build.rs b/crates/owlen-cli/build.rs deleted file mode 100644 index 16036bb..0000000 --- a/crates/owlen-cli/build.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::process::Command; - -fn main() { - const MIN_VERSION: (u32, u32, u32) = (1, 75, 0); - - let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); - let output = Command::new(&rustc) - .arg("--version") - .output() - .expect("failed to invoke rustc"); - - let version_line = String::from_utf8_lossy(&output.stdout); - let version_str = version_line.split_whitespace().nth(1).unwrap_or("0.0.0"); - let sanitized = version_str.split('-').next().unwrap_or(version_str); - - let mut parts = sanitized - .split('.') - .map(|part| part.parse::().unwrap_or(0)); - let current = ( - parts.next().unwrap_or(0), - parts.next().unwrap_or(0), - parts.next().unwrap_or(0), - ); - - if current < MIN_VERSION { - panic!( - "owlen requires rustc {}.{}.{} or newer (found {version_line})", - MIN_VERSION.0, MIN_VERSION.1, MIN_VERSION.2 - ); - } -} diff --git a/crates/owlen-cli/src/agent_main.rs b/crates/owlen-cli/src/agent_main.rs deleted file mode 100644 index e3bbe7a..0000000 --- a/crates/owlen-cli/src/agent_main.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Simple entry point for the ReAct agentic executor. -//! -//! Usage: `owlen-agent "" [--model ] [--max-iter ]` -//! -//! This binary demonstrates Phase 4 without the full TUI. It creates an -//! OllamaProvider, a RemoteMcpClient, runs the AgentExecutor and prints the -//! final answer. - -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; - -use anyhow::Context; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use clap::{Parser, builder::ValueHint}; -use image::{self, GenericImageView, imageops::FilterType}; -use owlen_cli::agent::{AgentConfig, AgentExecutor}; -use owlen_core::{mcp::remote_client::RemoteMcpClient, types::MessageAttachment}; -use tokio::fs; - -const MAX_ATTACHMENT_BYTES: u64 = 8 * 1024 * 1024; -const ATTACHMENT_ASCII_WIDTH: u32 = 24; -const ATTACHMENT_ASCII_HEIGHT: u32 = 12; -const ATTACHMENT_TEXT_PREVIEW_LINES: usize = 12; -const ATTACHMENT_TEXT_PREVIEW_WIDTH: usize = 80; -const ATTACHMENT_INLINE_PREVIEW_LINES: usize = 6; - -/// Command‑line arguments for the agent binary. -#[derive(Parser, Debug)] -#[command( - name = "owlen-agent", - author, - version, - about = "Run the ReAct agent via MCP" -)] -struct Args { - /// The initial user query. - prompt: String, - /// Paths to files that should be sent with the initial turn. - #[arg(long = "attach", short = 'a', value_name = "PATH", value_hint = ValueHint::FilePath)] - attachments: Vec, - /// Model to use (defaults to Ollama default). - #[arg(long)] - model: Option, - /// Maximum ReAct iterations. - #[arg(long, default_value_t = 10)] - max_iter: usize, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = Args::parse(); - let Args { - prompt, - attachments: attachment_paths, - model, - max_iter, - } = args; - - let attachments = load_attachments(&attachment_paths).await?; - if !attachments.is_empty() { - println!( - "Attaching {} {}:", - attachments.len(), - if attachments.len() == 1 { - "artifact" - } else { - "artifacts" - } - ); - render_attachment_previews(&attachments); - } - - // Initialise the MCP LLM client – it implements Provider and talks to the - // MCP LLM server which wraps Ollama. This ensures all communication goes - // through the MCP architecture (Phase 10 requirement). - let provider = Arc::new(RemoteMcpClient::new().await?); - - // The MCP client also serves as the tool client for resource operations - let mcp_client = Arc::clone(&provider) as Arc; - - let config = AgentConfig { - max_iterations: max_iter, - model: model.unwrap_or_else(|| "llama3.2:latest".to_string()), - ..AgentConfig::default() - }; - - let executor = AgentExecutor::new(provider, mcp_client, config); - match executor.run_with_attachments(prompt, attachments).await { - Ok(result) => { - println!("\n✓ Agent completed in {} iterations", result.iterations); - println!("\nFinal answer:\n{}", result.answer); - Ok(()) - } - Err(e) => Err(anyhow::anyhow!(e)), - } -} - -async fn load_attachments(paths: &[PathBuf]) -> anyhow::Result> { - let mut attachments = Vec::new(); - for path in paths { - let attachment = load_attachment(path).await?; - attachments.push(attachment); - } - Ok(attachments) -} - -async fn load_attachment(path: &Path) -> anyhow::Result { - let metadata = fs::metadata(path) - .await - .with_context(|| format!("Unable to inspect {}", path.display()))?; - if !metadata.is_file() { - return Err(anyhow::anyhow!("{} is not a regular file", path.display())); - } - if metadata.len() > MAX_ATTACHMENT_BYTES { - return Err(anyhow::anyhow!( - "Attachments are limited to {} (requested {}): {}", - format_attachment_size(MAX_ATTACHMENT_BYTES), - format_attachment_size(metadata.len()), - path.display() - )); - } - - let bytes = fs::read(path) - .await - .with_context(|| format!("Failed to read {}", path.display()))?; - let mime = mime_guess::from_path(path).first_or_octet_stream(); - let mime_string = mime.essence_str().to_string(); - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("attachment") - .to_string(); - - let is_text = mime_string.starts_with("text/") || std::str::from_utf8(&bytes).is_ok(); - let mut preview_lines = Vec::new(); - - let mut attachment = if is_text { - let text = String::from_utf8_lossy(&bytes).into_owned(); - preview_lines = preview_lines_for_text(&text); - let mut attachment = - MessageAttachment::from_text(Some(file_name.clone()), mime_string.clone(), text); - attachment.size_bytes = Some(metadata.len()); - attachment - } else { - if mime_string.starts_with("image/") - && let Some(lines) = preview_lines_for_image(&bytes) - { - preview_lines = lines; - } - let encoded = BASE64_STANDARD.encode(&bytes); - MessageAttachment::from_base64( - file_name.clone(), - mime_string.clone(), - encoded, - Some(metadata.len()), - ) - }; - - attachment.size_bytes = Some(metadata.len()); - attachment = attachment.with_source_path(path.to_path_buf()); - if !preview_lines.is_empty() { - attachment = attachment.with_preview_lines(preview_lines); - } - - Ok(attachment) -} - -fn render_attachment_previews(attachments: &[MessageAttachment]) { - for (idx, attachment) in attachments.iter().enumerate() { - println!(" {}. {}", idx + 1, summarize_attachment(attachment)); - if let Some(lines) = attachment.preview_lines.as_ref() { - for line in lines.iter().take(ATTACHMENT_INLINE_PREVIEW_LINES) { - println!(" {}", line); - } - if lines.len() > ATTACHMENT_INLINE_PREVIEW_LINES { - println!(" …"); - } - } - } - if !attachments.is_empty() { - println!(); - } -} - -fn summarize_attachment(attachment: &MessageAttachment) -> String { - let icon = if attachment.is_image() { - "📷" - } else if attachment - .mime_type - .to_ascii_lowercase() - .starts_with("text/") - { - "📄" - } else { - "📎" - }; - let name = attachment - .name - .as_deref() - .unwrap_or(attachment.mime_type.as_str()); - let mut parts = vec![format!("{icon} {name}"), attachment.mime_type.clone()]; - if let Some(size) = attachment.size_bytes { - parts.push(format_attachment_size(size)); - } - parts.join(" · ") -} - -fn format_attachment_size(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; - let mut value = bytes as f64; - let mut index = 0usize; - while value >= 1024.0 && index < UNITS.len() - 1 { - value /= 1024.0; - index += 1; - } - if index == 0 { - format!("{bytes} {}", UNITS[index]) - } else { - format!("{value:.1} {}", UNITS[index]) - } -} - -fn preview_lines_for_text(text: &str) -> Vec { - let mut lines = Vec::new(); - for raw in text.lines().take(ATTACHMENT_TEXT_PREVIEW_LINES) { - let trimmed = raw.trim_end(); - if trimmed.is_empty() { - lines.push(String::new()); - continue; - } - let mut snippet = trimmed - .chars() - .take(ATTACHMENT_TEXT_PREVIEW_WIDTH) - .collect::(); - if trimmed.chars().count() > ATTACHMENT_TEXT_PREVIEW_WIDTH { - snippet.push('…'); - } - lines.push(snippet); - } - - if lines.is_empty() { - lines.push("(empty attachment)".to_string()); - } - - lines -} - -fn preview_lines_for_image(bytes: &[u8]) -> Option> { - let image = image::load_from_memory(bytes).ok()?; - let (width, height) = image.dimensions(); - let mut lines = Vec::new(); - lines.push(format!("{width} × {height} px")); - - let target_width = ATTACHMENT_ASCII_WIDTH; - let target_height = ATTACHMENT_ASCII_HEIGHT; - let scale = (target_width as f32 / width as f32) - .min(target_height as f32 / height as f32) - .clamp(0.05, 1.0); - let scaled_width = (width as f32 * scale).max(1.0).round() as u32; - let scaled_height = (height as f32 * scale).max(1.0).round() as u32; - let resized = image - .resize_exact( - scaled_width.max(1), - scaled_height.max(1), - FilterType::Triangle, - ) - .to_luma8(); - - const PALETTE: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@']; - for y in 0..resized.height() { - let mut row = String::with_capacity((resized.width() as usize) * 2); - for x in 0..resized.width() { - let luminance = resized.get_pixel(x, y)[0] as usize; - let idx = luminance * (PALETTE.len() - 1) / 255; - let ch = PALETTE[idx]; - row.push(ch); - row.push(ch); - } - lines.push(row); - } - - Some(lines) -} diff --git a/crates/owlen-cli/src/bootstrap.rs b/crates/owlen-cli/src/bootstrap.rs deleted file mode 100644 index 90805db..0000000 --- a/crates/owlen-cli/src/bootstrap.rs +++ /dev/null @@ -1,340 +0,0 @@ -use std::borrow::Cow; -use std::io; -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use crossterm::{ - event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use futures::stream; -use owlen_core::{ - ChatStream, Error, Provider, - config::{Config, McpMode}, - mcp::remote_client::RemoteMcpClient, - mode::Mode, - provider::ProviderManager, - providers::OllamaProvider, - session::{ControllerEvent, SessionController}, - storage::StorageManager, - types::{ChatRequest, ChatResponse, Message, ModelInfo}, -}; -use owlen_tui::{ - ChatApp, SessionEvent, - app::App as RuntimeApp, - config, - tui_controller::{TuiController, TuiRequest}, - ui, -}; -use ratatui::{Terminal, prelude::CrosstermBackend}; -use tokio::sync::mpsc; - -use crate::commands::cloud::{load_runtime_credentials, set_env_var}; - -#[derive(Debug, Clone, Copy, Default)] -pub struct LaunchOptions { - pub disable_auto_compress: bool, -} - -pub async fn launch(initial_mode: Mode, options: LaunchOptions) -> Result<()> { - set_env_var("OWLEN_AUTO_CONSENT", "1"); - - let color_support = detect_terminal_color_support(); - let mut cfg = config::try_load_config().unwrap_or_default(); - let _ = cfg.refresh_mcp_servers(None); - - if options.disable_auto_compress { - cfg.chat.auto_compress = false; - } - - if let Some(previous_theme) = apply_terminal_theme(&mut cfg, &color_support) { - let term_label = match &color_support { - TerminalColorSupport::Limited { term } => Cow::from(term.as_str()), - TerminalColorSupport::Full => Cow::from("current terminal"), - }; - eprintln!( - "Terminal '{}' lacks full 256-color support. Using '{}' theme instead of '{}'.", - term_label, BASIC_THEME_NAME, previous_theme - ); - } else if let TerminalColorSupport::Limited { term } = &color_support { - eprintln!( - "Warning: terminal '{}' may not fully support 256-color themes.", - term - ); - } - - cfg.validate()?; - let storage = Arc::new(StorageManager::new().await?); - load_runtime_credentials(&mut cfg, storage.clone()).await?; - - let (tui_tx, _tui_rx) = mpsc::unbounded_channel::(); - let tui_controller = Arc::new(TuiController::new(tui_tx)); - - let provider = build_provider(&cfg).await?; - let mut offline_notice: Option = None; - let provider = match provider.health_check().await { - Ok(_) => provider, - Err(err) => { - let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly) - && !cfg.effective_mcp_servers().is_empty() - { - "Ensure the configured MCP server is running and reachable." - } else { - "Ensure Ollama is running (`ollama serve`) and reachable at the configured base_url." - }; - let notice = - format!("Provider health check failed: {err}. {hint} Continuing in offline mode."); - eprintln!("{notice}"); - offline_notice = Some(notice.clone()); - let fallback_model = cfg - .general - .default_model - .clone() - .unwrap_or_else(|| "offline".to_string()); - Arc::new(OfflineProvider::new(notice, fallback_model)) as Arc - } - }; - - let (controller_event_tx, controller_event_rx) = mpsc::unbounded_channel::(); - let controller = SessionController::new( - provider, - cfg, - storage.clone(), - tui_controller, - false, - Some(controller_event_tx), - ) - .await?; - let provider_manager = Arc::new(ProviderManager::default()); - let mut runtime = RuntimeApp::new(provider_manager); - let (mut app, mut session_rx) = ChatApp::new(controller, controller_event_rx).await?; - app.initialize_models().await?; - if let Some(notice) = offline_notice.clone() { - app.set_status_message(¬ice); - app.set_system_status(notice); - } - - if options.disable_auto_compress { - app.append_system_status("Auto compression off"); - } - - app.set_mode(initial_mode).await; - - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!( - stdout, - EnterAlternateScreen, - EnableMouseCapture, - EnableBracketedPaste - )?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let result = run_app(&mut terminal, &mut runtime, &mut app, &mut session_rx).await; - - config::save_config(&app.config())?; - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture, - DisableBracketedPaste - )?; - terminal.show_cursor()?; - - if let Err(err) = result { - println!("{err:?}"); - } - - Ok(()) -} - -async fn build_provider(cfg: &Config) -> Result> { - match cfg.mcp.mode { - McpMode::RemotePreferred => { - let remote_result = if let Some(mcp_server) = cfg.effective_mcp_servers().first() { - RemoteMcpClient::new_with_config(mcp_server).await - } else { - RemoteMcpClient::new().await - }; - - match remote_result { - Ok(client) => Ok(Arc::new(client) as Arc), - Err(err) if cfg.mcp.allow_fallback => { - log::warn!( - "Remote MCP client unavailable ({}); falling back to local provider.", - err - ); - build_local_provider(cfg) - } - Err(err) => Err(anyhow!(err)), - } - } - McpMode::RemoteOnly => { - let mcp_server = cfg.effective_mcp_servers().first().ok_or_else(|| { - anyhow!("[[mcp_servers]] must be configured when [mcp].mode = \"remote_only\"") - })?; - let client = RemoteMcpClient::new_with_config(mcp_server).await?; - Ok(Arc::new(client) as Arc) - } - McpMode::LocalOnly | McpMode::Legacy => build_local_provider(cfg), - McpMode::Disabled => Err(anyhow!( - "MCP mode 'disabled' is not supported by the owlen TUI" - )), - } -} - -fn build_local_provider(cfg: &Config) -> Result> { - let provider_name = cfg.general.default_provider.clone(); - let provider_cfg = cfg.provider(&provider_name).ok_or_else(|| { - anyhow!(format!( - "No provider configuration found for '{provider_name}' in [providers]" - )) - })?; - - match provider_cfg.provider_type.as_str() { - "ollama" | "ollama_cloud" => { - let provider = - OllamaProvider::from_config(&provider_name, provider_cfg, Some(&cfg.general))?; - Ok(Arc::new(provider) as Arc) - } - other => Err(anyhow!(format!( - "Provider type '{other}' is not supported in legacy/local MCP mode" - ))), - } -} - -const BASIC_THEME_NAME: &str = "ansi_basic"; - -#[derive(Debug, Clone)] -enum TerminalColorSupport { - Full, - Limited { term: String }, -} - -fn detect_terminal_color_support() -> TerminalColorSupport { - let term = std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string()); - let colorterm = std::env::var("COLORTERM").unwrap_or_default(); - let term_lower = term.to_lowercase(); - let color_lower = colorterm.to_lowercase(); - - let supports_extended = term_lower.contains("256color") - || color_lower.contains("truecolor") - || color_lower.contains("24bit") - || color_lower.contains("fullcolor"); - - if supports_extended { - TerminalColorSupport::Full - } else { - TerminalColorSupport::Limited { term } - } -} - -fn apply_terminal_theme(cfg: &mut Config, support: &TerminalColorSupport) -> Option { - match support { - TerminalColorSupport::Full => None, - TerminalColorSupport::Limited { .. } => { - if cfg.ui.theme != BASIC_THEME_NAME { - let previous = std::mem::replace(&mut cfg.ui.theme, BASIC_THEME_NAME.to_string()); - Some(previous) - } else { - None - } - } - } -} - -struct OfflineProvider { - reason: String, - placeholder_model: String, -} - -impl OfflineProvider { - fn new(reason: String, placeholder_model: String) -> Self { - Self { - reason, - placeholder_model, - } - } - - fn friendly_response(&self, requested_model: &str) -> ChatResponse { - let mut message = String::new(); - message.push_str("⚠️ Owlen is running in offline mode.\n\n"); - message.push_str(&self.reason); - if !requested_model.is_empty() && requested_model != self.placeholder_model { - message.push_str(&format!( - "\n\nYou requested model '{}', but no providers are reachable.", - requested_model - )); - } - message.push_str( - "\n\nStart your preferred provider (e.g. `ollama serve`) or switch providers with `:provider` once connectivity is restored.", - ); - - ChatResponse { - message: Message::assistant(message), - usage: None, - is_streaming: false, - is_final: true, - } - } -} - -#[async_trait] -impl Provider for OfflineProvider { - fn name(&self) -> &str { - "offline" - } - - async fn list_models(&self) -> Result, Error> { - Ok(vec![ModelInfo { - id: self.placeholder_model.clone(), - provider: "offline".to_string(), - name: format!("Offline (fallback: {})", self.placeholder_model), - description: Some("Placeholder model used while no providers are reachable".into()), - context_window: None, - capabilities: vec![], - supports_tools: false, - }]) - } - - async fn send_prompt(&self, request: ChatRequest) -> Result { - Ok(self.friendly_response(&request.model)) - } - - async fn stream_prompt(&self, request: ChatRequest) -> Result { - let response = self.friendly_response(&request.model); - Ok(Box::pin(stream::iter(vec![Ok(response)]))) - } - - async fn health_check(&self) -> Result<(), Error> { - Err(Error::Provider(anyhow!( - "offline provider cannot reach any backing models" - ))) - } - - fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) { - self - } -} - -async fn run_app( - terminal: &mut Terminal>, - runtime: &mut RuntimeApp, - app: &mut ChatApp, - session_rx: &mut mpsc::UnboundedReceiver, -) -> Result<()> { - let mut render = |terminal: &mut Terminal>, - state: &mut ChatApp| - -> Result<()> { - terminal.draw(|f| ui::render_chat(f, state))?; - Ok(()) - }; - - runtime.run(terminal, app, session_rx, &mut render).await?; - Ok(()) -} diff --git a/crates/owlen-cli/src/code_main.rs b/crates/owlen-cli/src/code_main.rs deleted file mode 100644 index f0d9cc6..0000000 --- a/crates/owlen-cli/src/code_main.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Owlen CLI entrypoint optimised for code-first workflows. -#![allow(dead_code, unused_imports)] - -mod bootstrap; -mod commands; -mod mcp; - -use anyhow::Result; -use owlen_core::config as core_config; -use owlen_core::mode::Mode; -use owlen_tui::config; - -#[tokio::main(flavor = "multi_thread")] -async fn main() -> Result<()> { - bootstrap::launch(Mode::Code, bootstrap::LaunchOptions::default()).await -} diff --git a/crates/owlen-cli/src/commands/cloud.rs b/crates/owlen-cli/src/commands/cloud.rs deleted file mode 100644 index def6295..0000000 --- a/crates/owlen-cli/src/commands/cloud.rs +++ /dev/null @@ -1,470 +0,0 @@ -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use anyhow::{Context, Result, anyhow, bail}; -use clap::Subcommand; -use owlen_core::LlmProvider; -use owlen_core::ProviderConfig; -use owlen_core::config::{ - self as core_config, Config, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, - LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, - OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, -}; -use owlen_core::credentials::{ApiCredentials, CredentialManager, OLLAMA_CLOUD_CREDENTIAL_ID}; -use owlen_core::encryption; -use owlen_core::providers::OllamaProvider; -use owlen_core::storage::StorageManager; -use serde_json::Value; - -const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL; -const CLOUD_ENDPOINT_KEY: &str = OLLAMA_CLOUD_ENDPOINT_KEY; -const CLOUD_PROVIDER_KEY: &str = "ollama_cloud"; - -#[derive(Debug, Subcommand)] -pub enum CloudCommand { - /// Configure Ollama Cloud credentials - Setup { - /// API key passed directly on the command line - #[arg(long)] - api_key: Option, - /// Override the cloud endpoint (default: https://ollama.com) - #[arg(long)] - endpoint: Option, - /// Provider name to configure (default: ollama_cloud) - #[arg(long, default_value = "ollama_cloud")] - provider: String, - /// Overwrite the provider base URL with the cloud endpoint - #[arg(long)] - force_cloud_base_url: bool, - }, - /// Check connectivity to Ollama Cloud - Status { - /// Provider name to check (default: ollama_cloud) - #[arg(long, default_value = "ollama_cloud")] - provider: String, - }, - /// List available cloud-hosted models - Models { - /// Provider name to query (default: ollama_cloud) - #[arg(long, default_value = "ollama_cloud")] - provider: String, - }, - /// Remove stored Ollama Cloud credentials - Logout { - /// Provider name to clear (default: ollama_cloud) - #[arg(long, default_value = "ollama_cloud")] - provider: String, - }, -} - -pub async fn run_cloud_command(command: CloudCommand) -> Result<()> { - match command { - CloudCommand::Setup { - api_key, - endpoint, - provider, - force_cloud_base_url, - } => setup(provider, api_key, endpoint, force_cloud_base_url).await, - CloudCommand::Status { provider } => status(provider).await, - CloudCommand::Models { provider } => models(provider).await, - CloudCommand::Logout { provider } => logout(provider).await, - } -} - -async fn setup( - provider: String, - api_key: Option, - endpoint: Option, - force_cloud_base_url: bool, -) -> Result<()> { - let provider = canonical_provider_name(&provider); - let mut config = crate::config::try_load_config().unwrap_or_default(); - let endpoint = - normalize_endpoint(&endpoint.unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string())); - - let base_changed = { - let entry = ensure_provider_entry(&mut config, &provider); - entry.enabled = true; - configure_cloud_endpoint(entry, &endpoint, force_cloud_base_url) - }; - - let mut credential_manager: Option> = None; - if config.privacy.encrypt_local_data { - let storage = Arc::new(StorageManager::new().await?); - credential_manager = Some(unlock_credential_manager(&config, storage)?); - } - - let mut key_opt = api_key.filter(|value| !value.trim().is_empty()); - - if key_opt.is_none() { - if let Some(manager) = credential_manager.as_ref() { - if let Some(credentials) = manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await? { - key_opt = Some(credentials.api_key); - } - } else if let Some(existing) = config - .provider(&provider) - .and_then(|cfg| cfg.api_key.clone()) - { - key_opt = Some(existing); - } - } - - let key = key_opt - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - anyhow!( - "API key is required when configuring provider `{provider}`. \ - Supply the --api-key flag, set providers.{provider}.api_key in config.toml, \ - or populate the credential vault." - ) - })?; - - if let Some(manager) = credential_manager.clone() { - let credentials = ApiCredentials { - api_key: key.clone(), - endpoint: endpoint.clone(), - }; - manager - .store_credentials(OLLAMA_CLOUD_CREDENTIAL_ID, &credentials) - .await?; - // Ensure plaintext key is not persisted to disk. - if let Some(entry) = config.providers.get_mut(&provider) { - entry.api_key = None; - } - } else if let Some(entry) = config.providers.get_mut(&provider) { - entry.api_key = Some(key.clone()); - } - - crate::config::save_config(&config)?; - println!("Saved Ollama configuration for provider '{provider}'."); - if config.privacy.encrypt_local_data { - println!("API key stored securely in the encrypted credential vault."); - } else { - println!("API key stored in plaintext configuration (encryption disabled)."); - } - if !force_cloud_base_url && !base_changed { - println!( - "Local base URL preserved; cloud endpoint stored as {}.", - CLOUD_ENDPOINT_KEY - ); - } - Ok(()) -} - -async fn status(provider: String) -> Result<()> { - let provider = canonical_provider_name(&provider); - let mut config = crate::config::try_load_config().unwrap_or_default(); - let storage = Arc::new(StorageManager::new().await?); - let manager = if config.privacy.encrypt_local_data { - Some(unlock_credential_manager(&config, storage.clone())?) - } else { - None - }; - - let api_key = hydrate_api_key(&mut config, manager.as_ref()).await?; - { - let entry = ensure_provider_entry(&mut config, &provider); - entry.enabled = true; - configure_cloud_endpoint(entry, DEFAULT_CLOUD_ENDPOINT, false); - } - - let provider_cfg = config - .provider(&provider) - .cloned() - .ok_or_else(|| anyhow!("Provider '{provider}' is not configured"))?; - - let endpoint = - resolve_cloud_endpoint(&provider_cfg).unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()); - let mut runtime_cfg = provider_cfg.clone(); - runtime_cfg.base_url = Some(endpoint.clone()); - runtime_cfg.extra.insert( - OLLAMA_MODE_KEY.to_string(), - Value::String("cloud".to_string()), - ); - - let ollama = OllamaProvider::from_config(&provider, &runtime_cfg, Some(&config.general)) - .with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?; - - match ollama.health_check().await { - Ok(_) => { - println!("✓ Connected to {provider} ({})", endpoint); - if api_key.is_none() && config.privacy.encrypt_local_data { - println!( - "Warning: No API key stored; connection succeeded via environment variables." - ); - } - } - Err(err) => { - println!("✗ Failed to reach {provider}: {err}"); - } - } - - Ok(()) -} - -async fn models(provider: String) -> Result<()> { - let provider = canonical_provider_name(&provider); - let mut config = crate::config::try_load_config().unwrap_or_default(); - let storage = Arc::new(StorageManager::new().await?); - let manager = if config.privacy.encrypt_local_data { - Some(unlock_credential_manager(&config, storage.clone())?) - } else { - None - }; - hydrate_api_key(&mut config, manager.as_ref()).await?; - - { - let entry = ensure_provider_entry(&mut config, &provider); - entry.enabled = true; - configure_cloud_endpoint(entry, DEFAULT_CLOUD_ENDPOINT, false); - } - - let provider_cfg = config - .provider(&provider) - .cloned() - .ok_or_else(|| anyhow!("Provider '{provider}' is not configured"))?; - - let endpoint = - resolve_cloud_endpoint(&provider_cfg).unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()); - let mut runtime_cfg = provider_cfg.clone(); - runtime_cfg.base_url = Some(endpoint); - runtime_cfg.extra.insert( - OLLAMA_MODE_KEY.to_string(), - Value::String("cloud".to_string()), - ); - - let ollama = OllamaProvider::from_config(&provider, &runtime_cfg, Some(&config.general)) - .with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?; - - match ollama.list_models().await { - Ok(models) => { - if models.is_empty() { - println!("No cloud models reported by '{}'.", provider); - } else { - println!("Models available via '{}':", provider); - for model in models { - if let Some(description) = &model.description { - println!(" - {} ({})", model.id, description); - } else { - println!(" - {}", model.id); - } - } - } - } - Err(err) => { - bail!("Failed to list models: {err}"); - } - } - - Ok(()) -} - -async fn logout(provider: String) -> Result<()> { - let provider = canonical_provider_name(&provider); - let mut config = crate::config::try_load_config().unwrap_or_default(); - let storage = Arc::new(StorageManager::new().await?); - - if config.privacy.encrypt_local_data { - let manager = unlock_credential_manager(&config, storage.clone())?; - manager - .delete_credentials(OLLAMA_CLOUD_CREDENTIAL_ID) - .await?; - } - - if let Some(entry) = config.providers.get_mut(&provider) { - entry.api_key = None; - entry.enabled = false; - } - - crate::config::save_config(&config)?; - println!("Cleared credentials for provider '{provider}'."); - Ok(()) -} - -fn ensure_provider_entry<'a>(config: &'a mut Config, provider: &str) -> &'a mut ProviderConfig { - core_config::ensure_provider_config_mut(config, provider) -} - -fn configure_cloud_endpoint(entry: &mut ProviderConfig, endpoint: &str, force: bool) -> bool { - let normalized = normalize_endpoint(endpoint); - let previous_base = entry.base_url.clone(); - entry.extra.insert( - CLOUD_ENDPOINT_KEY.to_string(), - Value::String(normalized.clone()), - ); - - let should_update_env = match entry.api_key_env.as_deref() { - None => true, - Some(value) => { - value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) - || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) - } - }; - if should_update_env { - entry.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); - } - - if force - || entry - .base_url - .as_ref() - .map(|value| value.trim().is_empty()) - .unwrap_or(true) - { - entry.base_url = Some(normalized.clone()); - } - - if force { - entry.enabled = true; - } - - entry.base_url != previous_base -} - -fn resolve_cloud_endpoint(cfg: &ProviderConfig) -> Option { - if let Some(value) = cfg - .extra - .get(CLOUD_ENDPOINT_KEY) - .and_then(|value| value.as_str()) - .map(normalize_endpoint) - { - return Some(value); - } - - cfg.base_url - .as_ref() - .map(|value| value.trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()) -} - -fn normalize_endpoint(endpoint: &str) -> String { - let trimmed = endpoint.trim().trim_end_matches('/'); - if trimmed.is_empty() { - DEFAULT_CLOUD_ENDPOINT.to_string() - } else { - trimmed.to_string() - } -} - -fn canonical_provider_name(provider: &str) -> String { - let normalized = provider.trim().to_ascii_lowercase().replace('-', "_"); - match normalized.as_str() { - "" => CLOUD_PROVIDER_KEY.to_string(), - "ollama" => CLOUD_PROVIDER_KEY.to_string(), - "ollama_cloud" => CLOUD_PROVIDER_KEY.to_string(), - value => value.to_string(), - } -} - -pub(crate) fn set_env_var(key: K, value: V) -where - K: AsRef, - V: AsRef, -{ - // Safety: the CLI updates process-wide environment variables during startup while no - // other threads are mutating the environment. - unsafe { - std::env::set_var(key, value); - } -} - -fn set_env_if_missing(var: &str, value: &str) { - if std::env::var(var) - .map(|v| v.trim().is_empty()) - .unwrap_or(true) - { - set_env_var(var, value); - } -} - -fn unlock_credential_manager( - config: &Config, - storage: Arc, -) -> Result> { - if !config.privacy.encrypt_local_data { - bail!("Credential manager requested but encryption is disabled"); - } - - let secure_path = vault_path(&storage)?; - let handle = unlock_vault(&secure_path)?; - let master_key = Arc::new(handle.data.master_key.clone()); - Ok(Arc::new(CredentialManager::new( - storage, - master_key.clone(), - ))) -} - -fn vault_path(storage: &StorageManager) -> Result { - let base_dir = storage - .database_path() - .parent() - .map(|p| p.to_path_buf()) - .or_else(dirs::data_local_dir) - .unwrap_or_else(|| PathBuf::from(".")); - Ok(base_dir.join("encrypted_data.json")) -} - -fn unlock_vault(path: &Path) -> Result { - encryption::unlock(path.to_path_buf()) -} - -async fn hydrate_api_key( - config: &mut Config, - manager: Option<&Arc>, -) -> Result> { - let credentials = match manager { - Some(manager) => manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await?, - None => None, - }; - - if let Some(credentials) = credentials { - let key = credentials.api_key.trim().to_string(); - if !key.is_empty() { - set_env_if_missing("OLLAMA_API_KEY", &key); - set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key); - } - - let cfg = core_config::ensure_provider_config_mut(config, CLOUD_PROVIDER_KEY); - configure_cloud_endpoint(cfg, &credentials.endpoint, false); - return Ok(Some(key)); - } - - if let Some(key) = config - .provider(CLOUD_PROVIDER_KEY) - .and_then(|cfg| cfg.api_key.as_ref()) - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - set_env_if_missing("OLLAMA_API_KEY", key); - set_env_if_missing("OLLAMA_CLOUD_API_KEY", key); - return Ok(Some(key.to_string())); - } - Ok(None) -} - -pub async fn load_runtime_credentials( - config: &mut Config, - storage: Arc, -) -> Result<()> { - if config.privacy.encrypt_local_data { - let manager = unlock_credential_manager(config, storage.clone())?; - hydrate_api_key(config, Some(&manager)).await?; - } else { - hydrate_api_key(config, None).await?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn canonicalises_provider_names() { - assert_eq!(canonical_provider_name("OLLAMA_CLOUD"), CLOUD_PROVIDER_KEY); - assert_eq!(canonical_provider_name(" ollama-cloud"), CLOUD_PROVIDER_KEY); - assert_eq!(canonical_provider_name(""), CLOUD_PROVIDER_KEY); - } -} diff --git a/crates/owlen-cli/src/commands/mod.rs b/crates/owlen-cli/src/commands/mod.rs deleted file mode 100644 index 09a7816..0000000 --- a/crates/owlen-cli/src/commands/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Command implementations for the `owlen` CLI. - -pub mod cloud; -pub mod providers; -pub mod repo; -pub mod security; -pub mod tools; diff --git a/crates/owlen-cli/src/commands/providers.rs b/crates/owlen-cli/src/commands/providers.rs deleted file mode 100644 index 67842e0..0000000 --- a/crates/owlen-cli/src/commands/providers.rs +++ /dev/null @@ -1,800 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use clap::{Args, Subcommand}; -use owlen_core::ProviderConfig; -use owlen_core::config::{self as core_config, Config}; -use owlen_core::provider::{ - AnnotatedModelInfo, ModelProvider, ProviderManager, ProviderStatus, ProviderType, -}; -use owlen_core::storage::StorageManager; -use owlen_core::tools::{WEB_SEARCH_TOOL_NAME, tool_name_matches}; -use owlen_providers::ollama::{OllamaCloudProvider, OllamaLocalProvider}; -use owlen_tui::config as tui_config; - -use super::cloud; - -/// CLI subcommands for provider management. -#[derive(Debug, Subcommand)] -pub enum ProvidersCommand { - /// List configured providers and their metadata. - List, - /// Run health checks against providers. - Status { - /// Optional provider identifier to check. - #[arg(value_name = "PROVIDER")] - provider: Option, - }, - /// Enable a provider in the configuration. - Enable { - /// Provider identifier to enable. - provider: String, - }, - /// Disable a provider in the configuration. - Disable { - /// Provider identifier to disable. - provider: String, - }, - /// Enable or disable the `web_search` tool exposure. - Web(WebCommand), -} - -/// Arguments for the `owlen models` command. -#[derive(Debug, Default, Args)] -pub struct ModelsArgs { - /// Restrict output to a specific provider. - #[arg(long)] - pub provider: Option, -} - -/// Arguments for managing the `web_search` tool exposure. -#[derive(Debug, Args)] -pub struct WebCommand { - /// Enable the `web_search` tool and allow remote lookups. - #[arg(long, conflicts_with = "disable")] - enable: bool, - /// Disable the `web_search` tool to keep sessions local-only. - #[arg(long, conflicts_with = "enable")] - disable: bool, -} - -impl WebCommand { - fn desired_state(&self) -> Option { - if self.enable { - Some(true) - } else if self.disable { - Some(false) - } else { - None - } - } -} - -pub async fn run_providers_command(command: ProvidersCommand) -> Result<()> { - match command { - ProvidersCommand::List => list_providers(), - ProvidersCommand::Status { provider } => status_providers(provider.as_deref()).await, - ProvidersCommand::Enable { provider } => toggle_provider(&provider, true), - ProvidersCommand::Disable { provider } => toggle_provider(&provider, false), - ProvidersCommand::Web(args) => handle_web_command(args), - } -} - -pub async fn run_models_command(args: ModelsArgs) -> Result<()> { - list_models(args.provider.as_deref()).await -} - -fn list_providers() -> Result<()> { - let config = tui_config::try_load_config().unwrap_or_default(); - let default_provider = canonical_provider_id(&config.general.default_provider); - - let mut rows = Vec::new(); - for (id, cfg) in &config.providers { - let type_label = describe_provider_type(id, cfg); - let auth_label = describe_auth(cfg, requires_auth(id, cfg)); - let enabled = if cfg.enabled { "yes" } else { "no" }; - let default = if id == &default_provider { "*" } else { "" }; - let base = cfg - .base_url - .as_ref() - .map(|value| value.trim().to_string()) - .unwrap_or_else(|| "-".to_string()); - - rows.push(ProviderListRow { - id: id.to_string(), - type_label, - enabled: enabled.to_string(), - default: default.to_string(), - auth: auth_label, - base_url: base, - }); - } - - rows.sort_by(|a, b| a.id.cmp(&b.id)); - - let id_width = rows - .iter() - .map(|row| row.id.len()) - .max() - .unwrap_or(8) - .max("Provider".len()); - let enabled_width = rows - .iter() - .map(|row| row.enabled.len()) - .max() - .unwrap_or(7) - .max("Enabled".len()); - let default_width = rows - .iter() - .map(|row| row.default.len()) - .max() - .unwrap_or(7) - .max("Default".len()); - let type_width = rows - .iter() - .map(|row| row.type_label.len()) - .max() - .unwrap_or(4) - .max("Type".len()); - let auth_width = rows - .iter() - .map(|row| row.auth.len()) - .max() - .unwrap_or(4) - .max("Auth".len()); - - println!( - "{:) -> Result<()> { - let mut config = tui_config::try_load_config().unwrap_or_default(); - let filter = filter.map(canonical_provider_id); - verify_provider_filter(&config, filter.as_deref())?; - - let storage = Arc::new(StorageManager::new().await?); - cloud::load_runtime_credentials(&mut config, storage.clone()).await?; - - let manager = ProviderManager::new(&config); - let records = register_enabled_providers(&manager, &config, filter.as_deref()).await?; - let health = manager.refresh_health().await; - - let mut rows = Vec::new(); - for record in records { - let status = health.get(&record.id).copied(); - rows.push(ProviderStatusRow::from_record(record, status)); - } - - rows.sort_by(|a, b| a.id.cmp(&b.id)); - print_status_rows(&rows); - Ok(()) -} - -async fn list_models(filter: Option<&str>) -> Result<()> { - let mut config = tui_config::try_load_config().unwrap_or_default(); - let filter = filter.map(canonical_provider_id); - verify_provider_filter(&config, filter.as_deref())?; - - let storage = Arc::new(StorageManager::new().await?); - cloud::load_runtime_credentials(&mut config, storage.clone()).await?; - - let manager = ProviderManager::new(&config); - let records = register_enabled_providers(&manager, &config, filter.as_deref()).await?; - let models = manager - .list_all_models() - .await - .map_err(|err| anyhow!(err))?; - let statuses = manager.provider_statuses().await; - - print_models(records, models, statuses); - Ok(()) -} - -fn verify_provider_filter(config: &Config, filter: Option<&str>) -> Result<()> { - if let Some(filter) = filter - && !config.providers.contains_key(filter) - { - return Err(anyhow!( - "Provider '{}' is not defined in configuration.", - filter - )); - } - Ok(()) -} - -fn handle_web_command(args: WebCommand) -> Result<()> { - let mut config = tui_config::try_load_config().unwrap_or_default(); - let initial = web_tool_enabled(&config); - - if let Some(desired) = args.desired_state() { - apply_web_toggle(&mut config, desired); - tui_config::save_config(&config).map_err(|err| anyhow!(err))?; - - if initial == desired { - println!( - "Web search tool already {}.", - if desired { "enabled" } else { "disabled" } - ); - } else { - println!( - "Web search tool {}.", - if desired { "enabled" } else { "disabled" } - ); - } - println!( - "Remote search is {}.", - if config.privacy.enable_remote_search { - "enabled" - } else { - "disabled" - } - ); - } else { - println!( - "Web search tool is {}.", - if initial { "enabled" } else { "disabled" } - ); - println!( - "Remote search is {}.", - if config.privacy.enable_remote_search { - "enabled" - } else { - "disabled" - } - ); - } - - Ok(()) -} - -fn apply_web_toggle(config: &mut Config, enabled: bool) { - config.tools.web_search.enabled = enabled; - config.privacy.enable_remote_search = enabled; - - config - .security - .allowed_tools - .retain(|tool| !tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)); - - if enabled { - config - .security - .allowed_tools - .push(WEB_SEARCH_TOOL_NAME.to_string()); - } -} - -fn web_tool_enabled(config: &Config) -> bool { - config.tools.web_search.enabled && config.privacy.enable_remote_search -} - -fn toggle_provider(provider: &str, enable: bool) -> Result<()> { - let mut config = tui_config::try_load_config().unwrap_or_default(); - let canonical = canonical_provider_id(provider); - if canonical.is_empty() { - return Err(anyhow!("Provider name cannot be empty.")); - } - - let previous_default = config.general.default_provider.clone(); - let previous_fallback_enabled = config.providers.get("ollama_local").map(|cfg| cfg.enabled); - - let previous_enabled; - { - let entry = core_config::ensure_provider_config_mut(&mut config, &canonical); - previous_enabled = entry.enabled; - if previous_enabled == enable { - println!( - "Provider '{}' is already {}.", - canonical, - if enable { "enabled" } else { "disabled" } - ); - return Ok(()); - } - entry.enabled = enable; - } - - if !enable && config.general.default_provider == canonical { - if let Some(candidate) = choose_fallback_provider(&config, &canonical) { - config.general.default_provider = candidate.clone(); - println!( - "Default provider set to '{}' because '{}' was disabled.", - candidate, canonical - ); - } else { - let entry = core_config::ensure_provider_config_mut(&mut config, "ollama_local"); - entry.enabled = true; - config.general.default_provider = "ollama_local".to_string(); - println!( - "Enabled 'ollama_local' and made it default because no other providers are active." - ); - } - } - - if let Err(err) = config.validate() { - { - let entry = core_config::ensure_provider_config_mut(&mut config, &canonical); - entry.enabled = previous_enabled; - } - config.general.default_provider = previous_default; - if let Some(enabled) = previous_fallback_enabled - && let Some(entry) = config.providers.get_mut("ollama_local") - { - entry.enabled = enabled; - } - return Err(anyhow!(err)); - } - - tui_config::save_config(&config).map_err(|err| anyhow!(err))?; - - println!( - "{} provider '{}'.", - if enable { "Enabled" } else { "Disabled" }, - canonical - ); - Ok(()) -} - -fn choose_fallback_provider(config: &Config, exclude: &str) -> Option { - if exclude != "ollama_local" - && let Some(cfg) = config.providers.get("ollama_local") - && cfg.enabled - { - return Some("ollama_local".to_string()); - } - - let mut candidates: Vec = config - .providers - .iter() - .filter(|(id, cfg)| cfg.enabled && id.as_str() != exclude) - .map(|(id, _)| id.clone()) - .collect(); - candidates.sort(); - candidates.into_iter().next() -} - -async fn register_enabled_providers( - manager: &ProviderManager, - config: &Config, - filter: Option<&str>, -) -> Result> { - let default_provider = canonical_provider_id(&config.general.default_provider); - let mut records = Vec::new(); - - for (id, cfg) in &config.providers { - if let Some(filter) = filter - && id != filter - { - continue; - } - - let mut record = ProviderRecord::from_config(id, cfg, id == &default_provider); - if !cfg.enabled { - records.push(record); - continue; - } - - match instantiate_provider(id, cfg) { - Ok(provider) => { - let metadata = provider.metadata().clone(); - record.provider_type_label = provider_type_label(metadata.provider_type); - record.requires_auth = metadata.requires_auth; - record.metadata = Some(metadata); - manager.register_provider(provider).await; - } - Err(err) => { - record.registration_error = Some(err.to_string()); - } - } - - records.push(record); - } - - records.sort_by(|a, b| a.id.cmp(&b.id)); - Ok(records) -} - -fn instantiate_provider(id: &str, cfg: &ProviderConfig) -> Result> { - let kind = cfg.provider_type.trim().to_ascii_lowercase(); - if kind == "ollama" || id == "ollama_local" { - let provider = OllamaLocalProvider::new(cfg.base_url.clone(), None, None) - .map_err(|err| anyhow!(err))?; - Ok(Arc::new(provider)) - } else if kind == "ollama_cloud" || id == "ollama_cloud" { - let provider = OllamaCloudProvider::new(cfg.base_url.clone(), cfg.api_key.clone(), None) - .map_err(|err| anyhow!(err))?; - Ok(Arc::new(provider)) - } else { - Err(anyhow!( - "Provider '{}' uses unsupported type '{}'.", - id, - if kind.is_empty() { - "unknown" - } else { - kind.as_str() - } - )) - } -} - -fn describe_provider_type(id: &str, cfg: &ProviderConfig) -> String { - if cfg.provider_type.trim().eq_ignore_ascii_case("ollama") || id.ends_with("_local") { - "Local".to_string() - } else if cfg - .provider_type - .trim() - .eq_ignore_ascii_case("ollama_cloud") - || id.contains("cloud") - { - "Cloud".to_string() - } else { - "Custom".to_string() - } -} - -fn requires_auth(id: &str, cfg: &ProviderConfig) -> bool { - cfg.api_key.is_some() - || cfg.api_key_env.is_some() - || matches!(id, "ollama_cloud" | "openai" | "anthropic") -} - -fn describe_auth(cfg: &ProviderConfig, required: bool) -> String { - if let Some(env) = cfg - .api_key_env - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - format!("env:{env}") - } else if cfg - .api_key - .as_ref() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false) - { - "config".to_string() - } else if required { - "required".to_string() - } else { - "-".to_string() - } -} - -fn canonical_provider_id(raw: &str) -> String { - let trimmed = raw.trim().to_ascii_lowercase(); - if trimmed.is_empty() { - return trimmed; - } - - match trimmed.as_str() { - "ollama" | "ollama-local" => "ollama_local".to_string(), - "ollama_cloud" | "ollama-cloud" => "ollama_cloud".to_string(), - other => other.replace('-', "_"), - } -} - -fn provider_type_label(provider_type: ProviderType) -> String { - match provider_type { - ProviderType::Local => "Local".to_string(), - ProviderType::Cloud => "Cloud".to_string(), - } -} - -fn provider_status_strings(status: ProviderStatus) -> (&'static str, &'static str) { - match status { - ProviderStatus::Available => ("OK", "available"), - ProviderStatus::Unavailable => ("ERR", "unavailable"), - ProviderStatus::RequiresSetup => ("SETUP", "requires setup"), - } -} - -fn print_status_rows(rows: &[ProviderStatusRow]) { - let id_width = rows - .iter() - .map(|row| row.id.len()) - .max() - .unwrap_or(8) - .max("Provider".len()); - let type_width = rows - .iter() - .map(|row| row.provider_type.len()) - .max() - .unwrap_or(4) - .max("Type".len()); - let status_width = rows - .iter() - .map(|row| row.indicator.len() + 1 + row.status_label.len()) - .max() - .unwrap_or(6) - .max("State".len()); - - println!( - "{:, - models: Vec, - statuses: HashMap, -) { - let mut grouped: HashMap> = HashMap::new(); - for info in models { - grouped - .entry(info.provider_id.clone()) - .or_default() - .push(info); - } - - for record in records { - let status = statuses.get(&record.id).copied().or_else(|| { - if record.metadata.is_some() && record.registration_error.is_none() && record.enabled { - Some(ProviderStatus::Unavailable) - } else { - None - } - }); - - let (indicator, label, status_value) = if !record.enabled { - ("-", "disabled", None) - } else if record.registration_error.is_some() { - ("ERR", "error", None) - } else if let Some(status) = status { - let (indicator, label) = provider_status_strings(status); - (indicator, label, Some(status)) - } else { - ("?", "unknown", None) - }; - - let title = if record.default_provider { - format!("{} (default)", record.id) - } else { - record.id.clone() - }; - println!( - "{} {} [{}] {}", - indicator, title, record.provider_type_label, label - ); - - if let Some(err) = &record.registration_error { - println!(" error: {}", err); - println!(); - continue; - } - - if !record.enabled { - println!(" provider disabled"); - println!(); - continue; - } - - if let Some(entries) = grouped.get(&record.id) { - let mut entries = entries.clone(); - entries.sort_by(|a, b| a.model.name.cmp(&b.model.name)); - if entries.is_empty() { - println!(" (no models reported)"); - } else { - for entry in entries { - let mut line = format!(" - {}", entry.model.name); - if let Some(description) = &entry.model.description - && !description.trim().is_empty() - { - line.push_str(&format!(" — {}", description.trim())); - } - println!("{}", line); - } - } - } else { - println!(" (no models reported)"); - } - - if let Some(ProviderStatus::RequiresSetup) = status_value - && record.requires_auth - { - println!(" configure provider credentials or API key"); - } - println!(); - } -} - -struct ProviderListRow { - id: String, - type_label: String, - enabled: String, - default: String, - auth: String, - base_url: String, -} - -struct ProviderRecord { - id: String, - enabled: bool, - default_provider: bool, - provider_type_label: String, - requires_auth: bool, - registration_error: Option, - metadata: Option, -} - -impl ProviderRecord { - fn from_config(id: &str, cfg: &ProviderConfig, default_provider: bool) -> Self { - Self { - id: id.to_string(), - enabled: cfg.enabled, - default_provider, - provider_type_label: describe_provider_type(id, cfg), - requires_auth: requires_auth(id, cfg), - registration_error: None, - metadata: None, - } - } -} - -struct ProviderStatusRow { - id: String, - provider_type: String, - default_provider: bool, - indicator: String, - status_label: String, - detail: Option, -} - -impl ProviderStatusRow { - fn from_record(record: ProviderRecord, status: Option) -> Self { - if !record.enabled { - return Self { - id: record.id, - provider_type: record.provider_type_label, - default_provider: record.default_provider, - indicator: "-".to_string(), - status_label: "disabled".to_string(), - detail: None, - }; - } - - if let Some(err) = record.registration_error { - return Self { - id: record.id, - provider_type: record.provider_type_label, - default_provider: record.default_provider, - indicator: "ERR".to_string(), - status_label: "error".to_string(), - detail: Some(err), - }; - } - - if let Some(status) = status { - let (indicator, label) = provider_status_strings(status); - return Self { - id: record.id, - provider_type: record.provider_type_label, - default_provider: record.default_provider, - indicator: indicator.to_string(), - status_label: label.to_string(), - detail: if matches!(status, ProviderStatus::RequiresSetup) && record.requires_auth { - Some("credentials required".to_string()) - } else { - None - }, - }; - } - - Self { - id: record.id, - provider_type: record.provider_type_label, - default_provider: record.default_provider, - indicator: "?".to_string(), - status_label: "unknown".to_string(), - detail: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn apply_web_toggle_updates_flags_and_allowed_tools() { - let mut config = Config::default(); - config.privacy.enable_remote_search = false; - config.tools.web_search.enabled = false; - config.security.allowed_tools.clear(); - - apply_web_toggle(&mut config, true); - assert!(config.tools.web_search.enabled); - assert!(config.privacy.enable_remote_search); - assert_eq!( - 1, - config - .security - .allowed_tools - .iter() - .filter(|tool| tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)) - .count() - ); - - apply_web_toggle(&mut config, false); - assert!(!config.tools.web_search.enabled); - assert!(!config.privacy.enable_remote_search); - } - - #[test] - fn apply_web_toggle_does_not_duplicate_allowed_entries() { - let mut config = Config::default(); - config - .security - .allowed_tools - .retain(|tool| !tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)); - config - .security - .allowed_tools - .push(WEB_SEARCH_TOOL_NAME.to_string()); - - apply_web_toggle(&mut config, true); - apply_web_toggle(&mut config, true); - - assert_eq!( - 1, - config - .security - .allowed_tools - .iter() - .filter(|tool| tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)) - .count() - ); - } -} diff --git a/crates/owlen-cli/src/commands/repo.rs b/crates/owlen-cli/src/commands/repo.rs deleted file mode 100644 index b7dbbba..0000000 --- a/crates/owlen-cli/src/commands/repo.rs +++ /dev/null @@ -1,203 +0,0 @@ -use std::env; -use std::path::PathBuf; - -use anyhow::{Context, Result, anyhow}; -use clap::{Args, Subcommand, ValueEnum}; -use owlen_core::automation::repo::{ - CommitTemplate, DiffCaptureMode, PullRequestContext, PullRequestReview, RepoAutomation, - summarize_diff, -}; -use owlen_core::github::{GithubClient, GithubConfig}; - -/// Subcommands for repository automation helpers (commit templates, PR reviews, workflows). -#[allow(clippy::large_enum_variant)] -#[derive(Debug, Subcommand)] -pub enum RepoCommand { - /// Generate a conventional commit template from repository changes. - CommitTemplate(CommitTemplateArgs), - /// Produce a structured review for a pull request or local diff. - Review(ReviewArgs), -} - -#[derive(Debug, Args)] -pub struct CommitTemplateArgs { - /// Repository path (defaults to current directory). - #[arg(long, value_name = "PATH")] - pub repo: Option, - /// Output format for the generated template. - #[arg(long, value_enum, default_value_t = OutputFormat::Markdown)] - pub format: OutputFormat, - /// Include unstaged working tree changes instead of staged changes. - #[arg(long)] - pub working_tree: bool, -} - -#[derive(Debug, Args)] -pub struct ReviewArgs { - /// Repository path for local diff analysis. - #[arg(long, value_name = "PATH")] - pub repo: Option, - /// Base ref for local diff review (default: origin/main). - #[arg(long)] - pub base: Option, - /// Head ref for local diff review (default: HEAD). - #[arg(long)] - pub head: Option, - /// Owner of the GitHub repository. - #[arg(long)] - pub owner: Option, - /// Repository name on GitHub. - #[arg(long = "repo")] - pub repository: Option, - /// Pull request number to fetch from GitHub. - #[arg(long)] - pub number: Option, - /// GitHub personal access token (falls back to environment variable). - #[arg(long)] - pub token: Option, - /// Environment variable used to resolve the GitHub token. - #[arg(long, default_value = "GITHUB_TOKEN")] - pub token_env: String, - /// Custom GitHub API endpoint (for GitHub Enterprise). - #[arg(long)] - pub api_endpoint: Option, - /// Path to a diff file to analyse instead of hitting Git or GitHub. - #[arg(long, value_name = "FILE")] - pub diff_file: Option, - /// Output format for the review body. - #[arg(long, value_enum, default_value_t = OutputFormat::Markdown)] - pub format: OutputFormat, -} - -#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] -pub enum OutputFormat { - Text, - Markdown, - Json, -} - -pub async fn run_repo_command(command: RepoCommand) -> Result<()> { - match command { - RepoCommand::CommitTemplate(args) => handle_commit_template(args).await, - RepoCommand::Review(args) => handle_review(args).await, - } -} - -async fn handle_commit_template(args: CommitTemplateArgs) -> Result<()> { - let repo_hint = args.repo.clone().unwrap_or_else(|| PathBuf::from(".")); - let automation = RepoAutomation::from_path(&repo_hint)?; - let mode = if args.working_tree { - DiffCaptureMode::WorkingTree - } else { - DiffCaptureMode::Staged - }; - let template = automation.generate_commit_template(mode)?; - emit_commit_template(&template, args.format); - Ok(()) -} - -async fn handle_review(args: ReviewArgs) -> Result<()> { - if let Some(number) = args.number { - let owner = args - .owner - .as_deref() - .ok_or_else(|| anyhow!("--owner is required when --number is provided"))?; - let repo = args - .repository - .as_deref() - .ok_or_else(|| anyhow!("--repo is required when --number is provided"))?; - let token = args - .token - .or_else(|| env::var(&args.token_env).ok()) - .filter(|value| !value.trim().is_empty()); - let client = GithubClient::new(GithubConfig { - token, - api_endpoint: args.api_endpoint.clone(), - })?; - let details = client.pull_request(owner, repo, number).await?; - let review = PullRequestReview::from_diff(details.context, &details.diff); - emit_review_output(review, args.format); - return Ok(()); - } - - if let Some(path) = args.diff_file.as_ref() { - let diff = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read diff file {}", path.display()))?; - let stats = summarize_diff(&diff); - let diff_label = path - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| path.display().to_string()); - let context = PullRequestContext { - title: format!("Review for diff from {}", diff_label), - body: None, - author: None, - base_branch: args - .base - .clone() - .unwrap_or_else(|| "(unknown base)".to_string()), - head_branch: args.head.clone().unwrap_or_else(|| "(diff)".to_string()), - additions: stats.additions as u64, - deletions: stats.deletions as u64, - changed_files: stats.files as u64, - html_url: None, - }; - let review = PullRequestReview::from_diff(context, &diff); - emit_review_output(review, args.format); - return Ok(()); - } - - let repo_hint = args.repo.clone().unwrap_or_else(|| PathBuf::from(".")); - let automation = RepoAutomation::from_path(&repo_hint)?; - let review = automation.generate_pr_review(args.base.as_deref(), args.head.as_deref())?; - emit_review_output(review, args.format); - Ok(()) -} - -fn emit_commit_template(template: &CommitTemplate, format: OutputFormat) { - match format { - OutputFormat::Markdown => { - println!("{}", template.render_markdown()); - } - OutputFormat::Text => { - let markdown = template.render_markdown(); - for line in markdown.lines() { - println!("{}", line.trim_start_matches('-').trim()); - } - } - OutputFormat::Json => match serde_json::to_string_pretty(template) { - Ok(json) => println!("{}", json), - Err(err) => eprintln!("Failed to encode template as JSON: {}", err), - }, - } -} - -fn emit_review_output(review: PullRequestReview, format: OutputFormat) { - match format { - OutputFormat::Markdown => println!("{}", review.render_markdown()), - OutputFormat::Text => { - println!("{}", review.summary); - for highlight in review.highlights { - println!("* {}", highlight); - } - if !review.findings.is_empty() { - println!("Findings:"); - for finding in review.findings { - println!(" - [{}] {}", finding.severity, finding.message); - } - } - if !review.checklist.is_empty() { - println!("Checklist:"); - for item in review.checklist { - let mark = if item.completed { "x" } else { " " }; - println!(" - [{}] {}", mark, item.label); - } - } - } - OutputFormat::Json => match serde_json::to_string_pretty(&review) { - Ok(json) => println!("{}", json), - Err(err) => eprintln!("Failed to encode review as JSON: {}", err), - }, - } -} diff --git a/crates/owlen-cli/src/commands/security.rs b/crates/owlen-cli/src/commands/security.rs deleted file mode 100644 index 06f8d4e..0000000 --- a/crates/owlen-cli/src/commands/security.rs +++ /dev/null @@ -1,61 +0,0 @@ -use anyhow::Result; -use clap::{Subcommand, ValueEnum}; -use owlen_core::config::ApprovalMode; -use owlen_tui::config as tui_config; - -/// Security-related configuration commands. -#[derive(Debug, Subcommand)] -pub enum SecurityCommand { - /// Display the current approval mode. - Show, - /// Set the approval mode (auto, read-only, plan-first). - Approval { - /// Approval policy to apply to new sessions. - #[arg(value_enum)] - mode: ApprovalModeArg, - }, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum ApprovalModeArg { - Auto, - #[clap(name = "read-only")] - ReadOnly, - #[clap(name = "plan-first")] - PlanFirst, -} - -impl From for ApprovalMode { - fn from(value: ApprovalModeArg) -> Self { - match value { - ApprovalModeArg::Auto => ApprovalMode::Auto, - ApprovalModeArg::ReadOnly => ApprovalMode::ReadOnly, - ApprovalModeArg::PlanFirst => ApprovalMode::PlanFirst, - } - } -} - -pub fn run_security_command(command: SecurityCommand) -> Result<()> { - match command { - SecurityCommand::Show => show_approval_mode(), - SecurityCommand::Approval { mode } => set_approval_mode(mode.into()), - } -} - -fn show_approval_mode() -> Result<()> { - let config = tui_config::try_load_config().unwrap_or_default(); - println!( - "Current approval mode: {}", - config.security.approval_mode.as_str() - ); - Ok(()) -} - -fn set_approval_mode(mode: ApprovalMode) -> Result<()> { - let mut config = tui_config::try_load_config().unwrap_or_default(); - config.security.approval_mode = mode; - config.validate()?; - tui_config::save_config(&config)?; - println!("Set approval mode to {}.", mode.as_str()); - Ok(()) -} diff --git a/crates/owlen-cli/src/commands/tools.rs b/crates/owlen-cli/src/commands/tools.rs deleted file mode 100644 index 0f0fe71..0000000 --- a/crates/owlen-cli/src/commands/tools.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::str::FromStr; - -use anyhow::{Result, anyhow}; -use clap::{Args, Subcommand}; -use owlen_core::mcp::presets::{self, PresetTier}; -use owlen_tui::config as tui_config; - -/// CLI entry points for managing MCP tool presets. -#[derive(Debug, Subcommand)] -pub enum ToolsCommand { - /// Install a reference MCP tool preset. - Install(InstallArgs), - /// Audit the current MCP servers against a preset. - Audit(AuditArgs), -} - -#[derive(Debug, Args)] -pub struct InstallArgs { - /// Preset tier to install (standard, extended, full). - #[arg(value_parser = parse_preset)] - pub preset: PresetTier, - /// Remove MCP servers not included in the preset. - #[arg(long)] - pub prune: bool, -} - -#[derive(Debug, Args)] -pub struct AuditArgs { - /// Preset tier to audit (defaults to full). - #[arg(value_parser = parse_preset)] - pub preset: Option, -} - -pub fn run_tools_command(command: ToolsCommand) -> Result<()> { - match command { - ToolsCommand::Install(args) => install_preset(args), - ToolsCommand::Audit(args) => audit_preset(args), - } -} - -fn install_preset(args: InstallArgs) -> Result<()> { - let mut config = tui_config::try_load_config().unwrap_or_default(); - let report = presets::apply_preset(&mut config, args.preset, args.prune)?; - tui_config::save_config(&config)?; - - println!( - "Installed '{}' preset (prune = {}).", - args.preset.as_str(), - args.prune - ); - - if !report.added.is_empty() { - println!(" added: {}", report.added.join(", ")); - } - if !report.updated.is_empty() { - println!(" updated: {}", report.updated.join(", ")); - } - if !report.removed.is_empty() { - println!(" removed: {}", report.removed.join(", ")); - } - - if report.added.is_empty() && report.updated.is_empty() && report.removed.is_empty() { - println!(" no changes were necessary."); - } - - Ok(()) -} - -fn audit_preset(args: AuditArgs) -> Result<()> { - let config = tui_config::try_load_config().unwrap_or_default(); - let preset = args.preset.unwrap_or(PresetTier::Full); - let report = presets::audit_preset(&config, preset); - - println!("Audit for '{}' preset:", preset.as_str()); - if report.missing.is_empty() && report.mismatched.is_empty() && report.extra.is_empty() { - println!(" configuration already matches this preset."); - return Ok(()); - } - - if !report.missing.is_empty() { - println!(" missing connectors:"); - for missing in report.missing { - println!(" - {}", missing.name); - } - } - - if !report.mismatched.is_empty() { - println!(" mismatched connectors:"); - for (expected, actual) in report.mismatched { - println!( - " - {} (expected command '{}', found '{}')", - expected.name, expected.command, actual.command - ); - } - } - - if !report.extra.is_empty() { - println!(" extra connectors:"); - for extra in report.extra { - println!(" - {}", extra.name); - } - } - - Ok(()) -} - -fn parse_preset(value: &str) -> Result { - PresetTier::from_str(value) - .map_err(|_| anyhow!("Unknown preset '{value}'. Use one of: standard, extended, full.")) -} diff --git a/crates/owlen-cli/src/lib.rs b/crates/owlen-cli/src/lib.rs deleted file mode 100644 index 4d63a0a..0000000 --- a/crates/owlen-cli/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Library portion of the `owlen-cli` crate. -//! -//! It currently only re‑exports the `agent` module used by the standalone -//! `owlen-agent` binary. Additional shared functionality can be added here in -//! the future. - -// Re-export agent module from owlen-core -pub use owlen_core::agent; diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs deleted file mode 100644 index 5c460d6..0000000 --- a/crates/owlen-cli/src/main.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! OWLEN CLI - Chat TUI client - -mod bootstrap; -mod commands; -mod mcp; - -use anyhow::{Result, anyhow}; -use clap::{Parser, Subcommand}; -use commands::{ - cloud::{CloudCommand, run_cloud_command}, - providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command}, - repo::{RepoCommand, run_repo_command}, - security::{SecurityCommand, run_security_command}, - tools::{ToolsCommand, run_tools_command}, -}; -use mcp::{McpCommand, run_mcp_command}; -use owlen_core::config::{ - self as core_config, Config, DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA, - DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS, - DEFAULT_PROVIDER_LIST_TTL_SECS, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OLLAMA_CLOUD_BASE_URL, - LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, McpMode, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, - OLLAMA_CLOUD_ENDPOINT_KEY, -}; -use owlen_core::mode::Mode; -use owlen_tui::config; -use serde_json::{Number as JsonNumber, Value as JsonValue}; -use std::env; - -/// Owlen - Terminal UI for LLM chat -#[derive(Parser, Debug)] -#[command(name = "owlen")] -#[command(about = "Terminal UI for LLM chat via MCP", long_about = None)] -struct Args { - /// Start in code mode (enables all tools) - #[arg(long, short = 'c')] - code: bool, - /// Disable automatic transcript compression for this session - #[arg(long)] - no_auto_compress: bool, - #[command(subcommand)] - command: Option, -} - -#[derive(Debug, Subcommand)] -enum OwlenCommand { - /// Inspect or upgrade configuration files - #[command(subcommand)] - Config(ConfigCommand), - /// Manage Ollama Cloud credentials - #[command(subcommand)] - Cloud(CloudCommand), - /// Manage model providers - #[command(subcommand)] - Providers(ProvidersCommand), - /// List models exposed by configured providers - Models(ModelsArgs), - /// Manage MCP server registrations - #[command(subcommand)] - Mcp(McpCommand), - /// Manage MCP tool presets - #[command(subcommand)] - Tools(ToolsCommand), - /// Configure security and approval policies - #[command(subcommand)] - Security(SecurityCommand), - /// Repository automation helpers (commit templates, PR reviews) - #[command(subcommand)] - Repo(RepoCommand), - /// Show manual steps for updating Owlen to the latest revision - Upgrade, -} - -#[derive(Debug, Subcommand)] -enum ConfigCommand { - /// Automatically upgrade legacy configuration values and ensure validity - Doctor, - /// Print the resolved configuration file path - Path, - /// Create a fresh configuration file using the latest defaults - Init { - /// Overwrite the existing configuration if present. - #[arg(long)] - force: bool, - }, -} - -async fn run_command(command: OwlenCommand) -> Result<()> { - match command { - OwlenCommand::Config(config_cmd) => run_config_command(config_cmd), - OwlenCommand::Cloud(cloud_cmd) => run_cloud_command(cloud_cmd).await, - OwlenCommand::Providers(provider_cmd) => run_providers_command(provider_cmd).await, - OwlenCommand::Models(args) => run_models_command(args).await, - OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd), - OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd), - OwlenCommand::Security(sec_cmd) => run_security_command(sec_cmd), - OwlenCommand::Repo(repo_cmd) => run_repo_command(repo_cmd).await, - OwlenCommand::Upgrade => { - println!( - "To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force" - ); - println!( - "If you installed from the AUR, use your package manager (e.g., yay -S owlen-git)." - ); - Ok(()) - } - } -} - -fn run_config_command(command: ConfigCommand) -> Result<()> { - match command { - ConfigCommand::Doctor => run_config_doctor(), - ConfigCommand::Path => { - let path = core_config::default_config_path(); - println!("{}", path.display()); - Ok(()) - } - ConfigCommand::Init { force } => run_config_init(force), - } -} - -fn run_config_init(force: bool) -> Result<()> { - let config_path = core_config::default_config_path(); - if config_path.exists() && !force { - return Err(anyhow!( - "Configuration already exists at {}. Re-run with --force to overwrite.", - config_path.display() - )); - } - - let mut config = Config::default(); - let _ = config.refresh_mcp_servers(None); - config.validate()?; - - config::save_config(&config)?; - println!("Wrote default configuration to {}.", config_path.display()); - Ok(()) -} - -fn run_config_doctor() -> Result<()> { - let config_path = core_config::default_config_path(); - let existed = config_path.exists(); - let mut config = config::try_load_config().unwrap_or_default(); - let _ = config.refresh_mcp_servers(None); - let mut changes = Vec::new(); - let mut warnings = Vec::new(); - - if !existed { - changes.push("created configuration file from defaults".to_string()); - } - - if config.provider(&config.general.default_provider).is_none() { - config.general.default_provider = "ollama_local".to_string(); - changes.push("default provider missing; reset to 'ollama_local'".to_string()); - } - - for key in ["ollama_local", "ollama_cloud", "openai", "anthropic"] { - if !config.providers.contains_key(key) { - core_config::ensure_provider_config_mut(&mut config, key); - changes.push(format!("added default configuration for provider '{key}'")); - } - } - - if let Some(local) = config.providers.get_mut("ollama_local") { - if ensure_numeric_extra_with_change( - &mut local.extra, - "list_ttl_secs", - DEFAULT_PROVIDER_LIST_TTL_SECS, - ) { - changes.push("added providers.ollama_local.list_ttl_secs (default 60)".to_string()); - } - if ensure_numeric_extra_with_change( - &mut local.extra, - "default_context_window", - u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), - ) { - changes.push(format!( - "added providers.ollama_local.default_context_window (default {})", - DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS - )); - } - if local.provider_type.trim().is_empty() || local.provider_type != "ollama" { - local.provider_type = "ollama".to_string(); - changes.push("normalised providers.ollama_local.provider_type to 'ollama'".to_string()); - } - } - - if let Some(cloud) = config.providers.get_mut("ollama_cloud") { - if cloud.provider_type.trim().is_empty() - || !cloud.provider_type.eq_ignore_ascii_case("ollama_cloud") - { - cloud.provider_type = "ollama_cloud".to_string(); - changes.push( - "normalised providers.ollama_cloud.provider_type to 'ollama_cloud'".to_string(), - ); - } - - let previous_base_url = cloud.base_url.clone(); - match cloud - .base_url - .as_ref() - .map(|value| value.trim_end_matches('/')) - { - None => { - cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); - } - Some(current) if current.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_BASE_URL) => { - cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); - } - _ => {} - } - if cloud.base_url != previous_base_url { - changes.push( - "normalised providers.ollama_cloud.base_url to https://ollama.com".to_string(), - ); - } - - let original_api_key_env = cloud.api_key_env.clone(); - let needs_env_update = cloud - .api_key_env - .as_ref() - .map(|value| value.trim().is_empty()) - .unwrap_or(true); - if needs_env_update { - cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); - } - if let Some(ref value) = original_api_key_env - && (value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) - || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)) - { - cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); - } - if cloud.api_key_env != original_api_key_env { - changes - .push("updated providers.ollama_cloud.api_key_env to 'OLLAMA_API_KEY'".to_string()); - } - - if ensure_string_extra_with_change( - &mut cloud.extra, - OLLAMA_CLOUD_ENDPOINT_KEY, - OLLAMA_CLOUD_BASE_URL, - ) { - changes.push( - "added providers.ollama_cloud.extra.cloud_endpoint (default https://ollama.com)" - .to_string(), - ); - } - if ensure_numeric_extra_with_change( - &mut cloud.extra, - "hourly_quota_tokens", - DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA, - ) { - changes.push(format!( - "added providers.ollama_cloud.hourly_quota_tokens (default {})", - DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA - )); - } - if ensure_numeric_extra_with_change( - &mut cloud.extra, - "weekly_quota_tokens", - DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, - ) { - changes.push(format!( - "added providers.ollama_cloud.weekly_quota_tokens (default {})", - DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA - )); - } - if ensure_numeric_extra_with_change( - &mut cloud.extra, - "list_ttl_secs", - DEFAULT_PROVIDER_LIST_TTL_SECS, - ) { - changes.push("added providers.ollama_cloud.list_ttl_secs (default 60)".to_string()); - } - if ensure_numeric_extra_with_change( - &mut cloud.extra, - "default_context_window", - u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), - ) { - changes.push(format!( - "added providers.ollama_cloud.default_context_window (default {})", - DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS - )); - } - } - - let canonical_env = env::var(OLLAMA_API_KEY_ENV) - .ok() - .filter(|value| !value.trim().is_empty()); - let legacy_env = env::var(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) - .ok() - .filter(|value| !value.trim().is_empty()); - let legacy_alt_env = env::var(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) - .ok() - .filter(|value| !value.trim().is_empty()); - - if canonical_env.is_some() { - if legacy_env.is_some() { - warnings.push(format!( - "Both {OLLAMA_API_KEY_ENV} and {LEGACY_OLLAMA_CLOUD_API_KEY_ENV} are set; Owlen will prefer {OLLAMA_API_KEY_ENV}." - )); - } - if legacy_alt_env.is_some() { - warnings.push(format!( - "Both {OLLAMA_API_KEY_ENV} and {LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV} are set; Owlen will prefer {OLLAMA_API_KEY_ENV}." - )); - } - } else { - if legacy_env.is_some() { - warnings.push(format!( - "Legacy environment variable {LEGACY_OLLAMA_CLOUD_API_KEY_ENV} is set. Rename it to {OLLAMA_API_KEY_ENV} to match the latest configuration schema." - )); - } - if legacy_alt_env.is_some() { - warnings.push(format!( - "Legacy environment variable {LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV} is set. Rename it to {OLLAMA_API_KEY_ENV} to match the latest configuration schema." - )); - } - } - - let mut ensure_default_enabled = true; - - if !config.providers.values().any(|cfg| cfg.enabled) { - let entry = core_config::ensure_provider_config_mut(&mut config, "ollama_local"); - if !entry.enabled { - entry.enabled = true; - changes.push("no providers were enabled; enabled 'ollama_local'".to_string()); - } - if config.general.default_provider != "ollama_local" { - config.general.default_provider = "ollama_local".to_string(); - changes.push( - "default provider reset to 'ollama_local' because no providers were enabled" - .to_string(), - ); - } - ensure_default_enabled = false; - } - - if ensure_default_enabled { - let default_id = config.general.default_provider.clone(); - if let Some(default_cfg) = config.providers.get(&default_id) && !default_cfg.enabled { - if let Some(new_default) = config - .providers - .iter() - .filter(|(id, cfg)| cfg.enabled && *id != &default_id) - .map(|(id, _)| id.clone()) - .min() - { - config.general.default_provider = new_default.clone(); - changes.push(format!( - "default provider '{default_id}' was disabled; switched default to '{new_default}'" - )); - } else { - let entry = - core_config::ensure_provider_config_mut(&mut config, "ollama_local"); - if !entry.enabled { - entry.enabled = true; - changes.push( - "enabled 'ollama_local' because default provider was disabled" - .to_string(), - ); - } - if config.general.default_provider != "ollama_local" { - config.general.default_provider = "ollama_local".to_string(); - changes.push( - "default provider reset to 'ollama_local' because previous default was disabled" - .to_string(), - ); - } - } - } - } - - match config.mcp.mode { - McpMode::Legacy => { - config.mcp.mode = McpMode::LocalOnly; - config.mcp.warn_on_legacy = true; - changes.push("converted [mcp].mode = 'legacy' to 'local_only'".to_string()); - } - McpMode::RemoteOnly if config.effective_mcp_servers().is_empty() => { - config.mcp.mode = McpMode::RemotePreferred; - config.mcp.allow_fallback = true; - changes.push( - "downgraded remote-only configuration to remote_preferred because no servers are defined" - .to_string(), - ); - } - McpMode::RemotePreferred - if !config.mcp.allow_fallback && config.effective_mcp_servers().is_empty() => - { - config.mcp.allow_fallback = true; - changes.push( - "enabled [mcp].allow_fallback because no remote servers are configured".to_string(), - ); - } - _ => {} - } - - config.validate()?; - config::save_config(&config)?; - - if changes.is_empty() { - println!( - "Configuration already up to date: {}", - config_path.display() - ); - } else { - println!("Updated {}:", config_path.display()); - for change in changes { - println!(" - {change}"); - } - } - - if !warnings.is_empty() { - println!("Warnings:"); - for warning in warnings { - println!(" - {warning}"); - } - } - - Ok(()) -} - -fn ensure_numeric_extra_with_change( - extra: &mut std::collections::HashMap, - key: &str, - default_value: u64, -) -> bool { - match extra.get_mut(key) { - Some(existing) => { - if existing.as_u64().is_some() { - false - } else { - *existing = JsonValue::Number(JsonNumber::from(default_value)); - true - } - } - None => { - extra.insert( - key.to_string(), - JsonValue::Number(JsonNumber::from(default_value)), - ); - true - } - } -} - -fn ensure_string_extra_with_change( - extra: &mut std::collections::HashMap, - key: &str, - default_value: &str, -) -> bool { - match extra.get_mut(key) { - Some(existing) => match existing.as_str() { - Some(value) if !value.trim().is_empty() => false, - _ => { - *existing = JsonValue::String(default_value.to_string()); - true - } - }, - None => { - extra.insert( - key.to_string(), - JsonValue::String(default_value.to_string()), - ); - true - } - } -} - -#[tokio::main(flavor = "multi_thread")] -async fn main() -> Result<()> { - // Parse command-line arguments - let Args { - code, - command, - no_auto_compress, - } = Args::parse(); - if let Some(command) = command { - return run_command(command).await; - } - let initial_mode = if code { Mode::Code } else { Mode::Chat }; - bootstrap::launch( - initial_mode, - bootstrap::LaunchOptions { - disable_auto_compress: no_auto_compress, - }, - ) - .await -} diff --git a/crates/owlen-cli/src/mcp.rs b/crates/owlen-cli/src/mcp.rs deleted file mode 100644 index 9b71749..0000000 --- a/crates/owlen-cli/src/mcp.rs +++ /dev/null @@ -1,260 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use anyhow::{Result, anyhow}; -use clap::{Args, Subcommand, ValueEnum}; -use owlen_core::config::{self as core_config, Config, McpConfigScope, McpServerConfig}; -use owlen_tui::config as tui_config; - -#[derive(Debug, Subcommand)] -pub enum McpCommand { - /// Add or update an MCP server in the selected scope - Add(AddArgs), - /// List MCP servers across scopes - List(ListArgs), - /// Remove an MCP server from a scope - Remove(RemoveArgs), -} - -pub fn run_mcp_command(command: McpCommand) -> Result<()> { - match command { - McpCommand::Add(args) => handle_add(args), - McpCommand::List(args) => handle_list(args), - McpCommand::Remove(args) => handle_remove(args), - } -} - -#[derive(Debug, Clone, Copy, ValueEnum, Default)] -pub enum ScopeArg { - User, - #[default] - Project, - Local, -} - -impl From for McpConfigScope { - fn from(value: ScopeArg) -> Self { - match value { - ScopeArg::User => McpConfigScope::User, - ScopeArg::Project => McpConfigScope::Project, - ScopeArg::Local => McpConfigScope::Local, - } - } -} - -#[derive(Debug, Args)] -pub struct AddArgs { - /// Logical name used to reference the server - pub name: String, - /// Command or endpoint invoked for the server - pub command: String, - /// Transport mechanism (stdio, http, websocket) - #[arg(long, default_value = "stdio")] - pub transport: String, - /// Configuration scope to write the server into - #[arg(long, value_enum, default_value_t = ScopeArg::Project)] - pub scope: ScopeArg, - /// Environment variables (KEY=VALUE) passed to the server process - #[arg(long = "env")] - pub env: Vec, - /// Additional arguments appended when launching the server - #[arg(trailing_var_arg = true, value_name = "ARG")] - pub args: Vec, -} - -#[derive(Debug, Args, Default)] -pub struct ListArgs { - /// Restrict output to a specific configuration scope - #[arg(long, value_enum)] - pub scope: Option, - /// Display only the effective servers (after precedence resolution) - #[arg(long)] - pub effective_only: bool, -} - -#[derive(Debug, Args)] -pub struct RemoveArgs { - /// Name of the server to remove - pub name: String, - /// Optional explicit scope to remove from - #[arg(long, value_enum)] - pub scope: Option, -} - -fn handle_add(args: AddArgs) -> Result<()> { - let mut config = load_config()?; - let scope: McpConfigScope = args.scope.into(); - let mut env_map = HashMap::new(); - for pair in &args.env { - let (key, value) = pair - .split_once('=') - .ok_or_else(|| anyhow!("Environment pairs must use KEY=VALUE syntax: '{}'", pair))?; - if key.trim().is_empty() { - return Err(anyhow!("Environment variable name cannot be empty")); - } - env_map.insert(key.trim().to_string(), value.to_string()); - } - - let server = McpServerConfig { - name: args.name.clone(), - command: args.command.clone(), - args: args.args.clone(), - transport: args.transport.to_lowercase(), - env: env_map, - oauth: None, - rpc_timeout_secs: None, - }; - - config.add_mcp_server(scope, server.clone(), None)?; - if matches!(scope, McpConfigScope::User) { - tui_config::save_config(&config)?; - } - - if let Some(path) = core_config::mcp_scope_path(scope, None) { - println!( - "Registered MCP server '{}' in {} scope ({})", - server.name, - scope, - path.display() - ); - } else { - println!( - "Registered MCP server '{}' in {} scope.", - server.name, scope - ); - } - - Ok(()) -} - -fn handle_list(args: ListArgs) -> Result<()> { - let mut config = load_config()?; - config.refresh_mcp_servers(None)?; - - let scoped = config.scoped_mcp_servers(); - if scoped.is_empty() { - println!("No MCP servers configured."); - return Ok(()); - } - - let filter_scope = args.scope.map(|scope| scope.into()); - let effective = config.effective_mcp_servers(); - let mut active = HashSet::new(); - for server in effective { - active.insert(( - server.name.clone(), - server.command.clone(), - server.transport.to_lowercase(), - )); - } - - println!( - "{:<2} {:<8} {:<20} {:<10} Command", - "", "Scope", "Name", "Transport" - ); - for entry in scoped { - if filter_scope - .as_ref() - .is_some_and(|target_scope| entry.scope != *target_scope) - { - continue; - } - - let payload = format_command_line(&entry.config.command, &entry.config.args); - let key = ( - entry.config.name.clone(), - entry.config.command.clone(), - entry.config.transport.to_lowercase(), - ); - let marker = if active.contains(&key) { "*" } else { " " }; - - if args.effective_only && marker != "*" { - continue; - } - - println!( - "{} {:<8} {:<20} {:<10} {}", - marker, entry.scope, entry.config.name, entry.config.transport, payload - ); - } - - let scoped_resources = config.scoped_mcp_resources(); - if !scoped_resources.is_empty() { - println!(); - println!("{:<2} {:<8} {:<30} Title", "", "Scope", "Resource"); - let effective_keys: HashSet<(String, String)> = config - .effective_mcp_resources() - .iter() - .map(|res| (res.server.clone(), res.uri.clone())) - .collect(); - - for entry in scoped_resources { - if filter_scope - .as_ref() - .is_some_and(|target_scope| entry.scope != *target_scope) - { - continue; - } - - let key = (entry.config.server.clone(), entry.config.uri.clone()); - let marker = if effective_keys.contains(&key) { - "*" - } else { - " " - }; - if args.effective_only && marker != "*" { - continue; - } - - let reference = format!("@{}:{}", entry.config.server, entry.config.uri); - let title = entry.config.title.as_deref().unwrap_or("—"); - - println!("{} {:<8} {:<30} {}", marker, entry.scope, reference, title); - } - } - - Ok(()) -} - -fn handle_remove(args: RemoveArgs) -> Result<()> { - let mut config = load_config()?; - let scope_hint = args.scope.map(|scope| scope.into()); - let result = config.remove_mcp_server(scope_hint, &args.name, None)?; - - match result { - Some(scope) => { - if matches!(scope, McpConfigScope::User) { - tui_config::save_config(&config)?; - } - - if let Some(path) = core_config::mcp_scope_path(scope, None) { - println!( - "Removed MCP server '{}' from {} scope ({})", - args.name, - scope, - path.display() - ); - } else { - println!("Removed MCP server '{}' from {} scope.", args.name, scope); - } - } - None => { - println!("No MCP server named '{}' was found.", args.name); - } - } - - Ok(()) -} - -fn load_config() -> Result { - let mut config = tui_config::try_load_config().unwrap_or_default(); - config.refresh_mcp_servers(None)?; - Ok(config) -} - -fn format_command_line(command: &str, args: &[String]) -> String { - if args.is_empty() { - command.to_string() - } else { - format!("{} {}", command, args.join(" ")) - } -} diff --git a/crates/owlen-cli/tests/agent_tests.rs b/crates/owlen-cli/tests/agent_tests.rs deleted file mode 100644 index 27d2433..0000000 --- a/crates/owlen-cli/tests/agent_tests.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Integration tests for the ReAct agent loop functionality. -//! -//! These tests verify that the agent executor correctly: -//! - Parses ReAct formatted responses -//! - Executes tool calls -//! - Handles multi-step workflows -//! - Recovers from errors -//! - Respects iteration limits - -use owlen_cli::agent::{AgentConfig, AgentExecutor, LlmResponse}; -use owlen_core::mcp::remote_client::RemoteMcpClient; -use owlen_core::tools::WEB_SEARCH_TOOL_NAME; -use std::sync::Arc; - -#[tokio::test] -async fn test_react_parsing_tool_call() { - let executor = create_test_executor().await; - - // Test parsing a tool call with JSON arguments - let text = "THOUGHT: I should search for information\nACTION: web_search\nACTION_INPUT: {\"query\": \"rust async programming\"}\n"; - - let result = executor.parse_response(text); - - match result { - Ok(LlmResponse::ToolCall { - thought, - tool_name, - arguments, - }) => { - assert_eq!(thought, "I should search for information"); - assert_eq!(tool_name.as_str(), WEB_SEARCH_TOOL_NAME); - assert_eq!(arguments["query"], "rust async programming"); - } - other => panic!("Expected ToolCall, got: {:?}", other), - } -} - -#[tokio::test] -async fn test_react_parsing_final_answer() { - let executor = create_test_executor().await; - - let text = "THOUGHT: I have enough information now\nFINAL_ANSWER: The answer is 42\n"; - - let result = executor.parse_response(text); - - match result { - Ok(LlmResponse::FinalAnswer { thought, answer }) => { - assert_eq!(thought, "I have enough information now"); - assert_eq!(answer, "The answer is 42"); - } - other => panic!("Expected FinalAnswer, got: {:?}", other), - } -} - -#[tokio::test] -async fn test_react_parsing_with_multiline_thought() { - let executor = create_test_executor().await; - - let text = "THOUGHT: This is a complex\nmulti-line thought\nACTION: list_files\nACTION_INPUT: {\"path\": \".\"}\n"; - - let result = executor.parse_response(text); - - // The regex currently only captures until first newline - // This test documents current behavior - match result { - Ok(LlmResponse::ToolCall { thought, .. }) => { - // Regex pattern stops at first \n after THOUGHT: - assert!(thought.contains("This is a complex")); - } - other => panic!("Expected ToolCall, got: {:?}", other), - } -} - -#[tokio::test] -#[ignore] // Requires MCP LLM server to be running -async fn test_agent_single_tool_scenario() { - // This test requires a running MCP LLM server (which wraps Ollama) - let provider = Arc::new(RemoteMcpClient::new().await.unwrap()); - let mcp_client = Arc::clone(&provider) as Arc; - - let config = AgentConfig { - max_iterations: 5, - model: "llama3.2".to_string(), - temperature: Some(0.7), - max_tokens: None, - ..AgentConfig::default() - }; - - let executor = AgentExecutor::new(provider, mcp_client, config); - - // Simple query that should complete in one tool call - let result = executor - .run("List files in the current directory".to_string()) - .await; - - match result { - Ok(agent_result) => { - assert!( - !agent_result.answer.is_empty(), - "Answer should not be empty" - ); - println!("Agent answer: {}", agent_result.answer); - } - Err(e) => { - // It's okay if this fails due to LLM not following format - println!("Agent test skipped: {}", e); - } - } -} - -#[tokio::test] -#[ignore] // Requires Ollama to be running -async fn test_agent_multi_step_workflow() { - // Test a query that requires multiple tool calls - let provider = Arc::new(RemoteMcpClient::new().await.unwrap()); - let mcp_client = Arc::clone(&provider) as Arc; - - let config = AgentConfig { - max_iterations: 10, - model: "llama3.2".to_string(), - temperature: Some(0.5), // Lower temperature for more consistent behavior - max_tokens: None, - ..AgentConfig::default() - }; - - let executor = AgentExecutor::new(provider, mcp_client, config); - - // Query requiring multiple steps: list -> read -> analyze - let result = executor - .run("Find all Rust files and tell me which one contains 'Agent'".to_string()) - .await; - - match result { - Ok(agent_result) => { - assert!(!agent_result.answer.is_empty()); - println!("Multi-step answer: {:?}", agent_result); - } - Err(e) => { - println!("Multi-step test skipped: {}", e); - } - } -} - -#[tokio::test] -#[ignore] // Requires Ollama -async fn test_agent_iteration_limit() { - let provider = Arc::new(RemoteMcpClient::new().await.unwrap()); - let mcp_client = Arc::clone(&provider) as Arc; - - let config = AgentConfig { - max_iterations: 2, // Very low limit to test enforcement - model: "llama3.2".to_string(), - temperature: Some(0.7), - max_tokens: None, - ..AgentConfig::default() - }; - - let executor = AgentExecutor::new(provider, mcp_client, config); - - // Complex query that would require many iterations - let result = executor - .run("Perform an exhaustive analysis of all files".to_string()) - .await; - - // Should hit the iteration limit (or parse error if LLM doesn't follow format) - match result { - Err(e) => { - let error_str = format!("{}", e); - // Accept either iteration limit error or parse error (LLM didn't follow ReAct format) - assert!( - error_str.contains("Maximum iterations") - || error_str.contains("2") - || error_str.contains("parse"), - "Expected iteration limit or parse error, got: {}", - error_str - ); - println!("Test passed: agent stopped with error: {}", error_str); - } - Ok(_) => { - // It's possible the LLM completed within 2 iterations - println!("Agent completed within iteration limit"); - } - } -} - -#[tokio::test] -#[ignore] // Requires Ollama -async fn test_agent_tool_budget_enforcement() { - let provider = Arc::new(RemoteMcpClient::new().await.unwrap()); - let mcp_client = Arc::clone(&provider) as Arc; - - let config = AgentConfig { - max_iterations: 3, // Very low iteration limit to enforce budget - model: "llama3.2".to_string(), - temperature: Some(0.7), - max_tokens: None, - ..AgentConfig::default() - }; - - let executor = AgentExecutor::new(provider, mcp_client, config); - - // Query that would require many tool calls - let result = executor - .run("Read every file in the project and summarize them all".to_string()) - .await; - - // Should hit the tool call budget (or parse error if LLM doesn't follow format) - match result { - Err(e) => { - let error_str = format!("{}", e); - // Accept either budget error or parse error (LLM didn't follow ReAct format) - assert!( - error_str.contains("Maximum iterations") - || error_str.contains("budget") - || error_str.contains("parse"), - "Expected budget or parse error, got: {}", - error_str - ); - println!("Test passed: agent stopped with error: {}", error_str); - } - Ok(_) => { - println!("Agent completed within tool budget"); - } - } -} - -// Helper function to create a test executor -// For parsing tests, we don't need a real connection -async fn create_test_executor() -> AgentExecutor { - // For parsing tests, we can accept the error from RemoteMcpClient::new() - // since we're only testing parse_response which doesn't use the MCP client - let provider = match RemoteMcpClient::new().await { - Ok(client) => Arc::new(client), - Err(_) => { - // If MCP server binary doesn't exist, parsing tests can still run - // by using a dummy client that will never be called - // This is a workaround for unit tests that only need parse_response - panic!("MCP server binary not found - build the project first with: cargo build --all"); - } - }; - - let mcp_client = Arc::clone(&provider) as Arc; - - let config = AgentConfig::default(); - AgentExecutor::new(provider, mcp_client, config) -} - -#[test] -fn test_agent_config_defaults() { - let config = AgentConfig::default(); - - assert_eq!(config.max_iterations, 15); - assert_eq!(config.model, "llama3.2:latest"); - assert_eq!(config.temperature, Some(0.7)); - assert_eq!(config.system_prompt, None); - assert!(config.sub_agents.is_empty()); - // max_tool_calls field removed - agent now tracks iterations instead -} - -#[test] -fn test_agent_config_custom() { - let config = AgentConfig { - max_iterations: 15, - model: "custom-model".to_string(), - temperature: Some(0.5), - max_tokens: Some(2000), - system_prompt: Some("Custom prompt".to_string()), - sub_agents: Vec::new(), - }; - - assert_eq!(config.max_iterations, 15); - assert_eq!(config.model, "custom-model"); - assert_eq!(config.temperature, Some(0.5)); - assert_eq!(config.max_tokens, Some(2000)); -} diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml deleted file mode 100644 index d9ade6b..0000000 --- a/crates/owlen-core/Cargo.toml +++ /dev/null @@ -1,52 +0,0 @@ -[package] -name = "owlen-core" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Core traits and types for OWLEN LLM client" - -[dependencies] -owlen-ui-common = { path = "../owlen-ui-common" } -anyhow = { workspace = true } -log = { workspace = true } -regex = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } -unicode-segmentation = "1.11" -unicode-width = "0.2" -uuid = { workspace = true } -textwrap = { workspace = true } -futures = { workspace = true } -futures-util = { workspace = true } -async-trait = { workspace = true } -toml = { workspace = true } -shellexpand = { workspace = true } -dirs = { workspace = true } -tempfile = { workspace = true } -jsonschema = { workspace = true } -which = { workspace = true } -nix = { workspace = true } -aes-gcm = { workspace = true } -ring = { workspace = true } -keyring = { workspace = true } -chrono = { workspace = true } -urlencoding = { workspace = true } -sqlx = { workspace = true } -reqwest = { workspace = true, features = ["default"] } -path-clean = "1.0" -tokio-stream = { workspace = true } -tokio-tungstenite = "0.21" -tungstenite = "0.21" -ollama-rs = { version = "=0.3.2", features = ["stream", "headers"] } -once_cell = { workspace = true } -base64 = { workspace = true } - -[dev-dependencies] -tokio-test = { workspace = true } -httpmock = "0.7" -wiremock = "0.6" diff --git a/crates/owlen-core/README.md b/crates/owlen-core/README.md deleted file mode 100644 index 80d5692..0000000 --- a/crates/owlen-core/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Owlen Core - -This crate provides the core abstractions and data structures for the Owlen ecosystem. - -It defines the essential traits and types that enable communication with various LLM providers, manage sessions, and handle configuration. - -## Key Components - -- **`Provider` trait**: The fundamental abstraction for all LLM providers. Implement this trait to add support for a new provider. -- **`Session`**: Represents a single conversation, managing message history and context. -- **`Model`**: Defines the structure for LLM models, including their names and properties. -- **Configuration**: Handles loading and parsing of the application's configuration. diff --git a/crates/owlen-core/migrations/0001_create_conversations.sql b/crates/owlen-core/migrations/0001_create_conversations.sql deleted file mode 100644 index 6b095fc..0000000 --- a/crates/owlen-core/migrations/0001_create_conversations.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS conversations ( - id TEXT PRIMARY KEY, - name TEXT, - description TEXT, - model TEXT NOT NULL, - message_count INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - data TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at DESC); diff --git a/crates/owlen-core/migrations/0002_create_secure_items.sql b/crates/owlen-core/migrations/0002_create_secure_items.sql deleted file mode 100644 index c789d4e..0000000 --- a/crates/owlen-core/migrations/0002_create_secure_items.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS secure_items ( - key TEXT PRIMARY KEY, - nonce BLOB NOT NULL, - ciphertext BLOB NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); diff --git a/crates/owlen-core/src/agent.rs b/crates/owlen-core/src/agent.rs deleted file mode 100644 index 61f9332..0000000 --- a/crates/owlen-core/src/agent.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Agentic execution loop with ReAct pattern support. -//! -//! This module provides the core agent orchestration logic that allows an LLM -//! to reason about tasks, execute tools, and observe results in an iterative loop. - -use crate::Provider; -use crate::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse}; -use crate::types::{ChatParameters, ChatRequest, Message, MessageAttachment}; -use crate::{Error, Result, SubAgentSpec}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -/// Maximum number of agent iterations before stopping -const DEFAULT_MAX_ITERATIONS: usize = 15; - -/// Parsed response from the LLM in ReAct format -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum LlmResponse { - /// LLM wants to execute a tool - ToolCall { - thought: String, - tool_name: String, - arguments: serde_json::Value, - }, - /// LLM has reached a final answer - FinalAnswer { thought: String, answer: String }, - /// LLM is just reasoning without taking action - Reasoning { thought: String }, -} - -fn assemble_prompt_with_tools_and_subagents( - base_prompt: &str, - tools: &[McpToolDescriptor], - sub_agents: &[SubAgentSpec], -) -> String { - let mut prompt = base_prompt.trim().to_string(); - prompt.push_str("\n\nYou have access to the following tools:\n"); - for tool in tools { - prompt.push_str(&format!("- {}: {}\n", tool.name, tool.description)); - } - append_subagent_guidance(&mut prompt, sub_agents); - prompt -} - -fn append_subagent_guidance(prompt: &mut String, sub_agents: &[SubAgentSpec]) { - if sub_agents.is_empty() { - return; - } - - prompt.push_str("\nYou may delegate focused tasks to the following specialised sub-agents:\n"); - for sub in sub_agents { - prompt.push_str(&format!( - "- {}: {}\n{}\n", - sub.name.as_deref().unwrap_or(sub.id.as_str()), - sub.description - .as_deref() - .unwrap_or("No description provided."), - sub.prompt.trim() - )); - } -} - -/// Parse error when LLM response doesn't match expected format -#[derive(Debug, thiserror::Error)] -pub enum ParseError { - #[error("No recognizable pattern found in response")] - NoPattern, - #[error("Missing required field: {0}")] - MissingField(String), - #[error("Invalid JSON in ACTION_INPUT: {0}")] - InvalidJson(String), -} - -/// Result of an agent execution -#[derive(Debug, Clone)] -pub struct AgentResult { - /// Final answer from the agent - pub answer: String, - /// Number of iterations taken - pub iterations: usize, - /// All messages exchanged during execution - pub messages: Vec, - /// Whether the agent completed successfully - pub success: bool, -} - -/// Configuration for agent execution -#[derive(Debug, Clone)] -pub struct AgentConfig { - /// Maximum number of iterations - pub max_iterations: usize, - /// Model to use for reasoning - pub model: String, - /// Temperature for LLM sampling - pub temperature: Option, - /// Max tokens per LLM call - pub max_tokens: Option, - /// Optional override for the system prompt presented to the LLM. - pub system_prompt: Option, - /// Optional sub-agent prompts exposed to the executor. - pub sub_agents: Vec, -} - -impl Default for AgentConfig { - fn default() -> Self { - Self { - max_iterations: DEFAULT_MAX_ITERATIONS, - model: "llama3.2:latest".to_string(), - temperature: Some(0.7), - max_tokens: Some(4096), - system_prompt: None, - sub_agents: Vec::new(), - } - } -} - -/// Agent executor that orchestrates the ReAct loop -pub struct AgentExecutor { - /// LLM provider for reasoning - llm_client: Arc, - /// MCP client for tool execution - tool_client: Arc, - /// Agent configuration - config: AgentConfig, -} - -impl AgentExecutor { - /// Create a new agent executor - pub fn new( - llm_client: Arc, - tool_client: Arc, - config: AgentConfig, - ) -> Self { - Self { - llm_client, - tool_client, - config, - } - } - - /// Run the agent loop with the given query - pub async fn run(&self, query: String) -> Result { - self.run_with_attachments(query, Vec::new()).await - } - - /// Run the agent loop with an initial multimodal payload. - pub async fn run_with_attachments( - &self, - query: String, - attachments: Vec, - ) -> Result { - let mut messages = vec![Message::user(query).with_attachments(attachments)]; - let tools = self.discover_tools().await?; - - for iteration in 0..self.config.max_iterations { - let prompt = self.build_react_prompt(&messages, &tools); - let response = self.generate_llm_response(prompt).await?; - - match self.parse_response(&response)? { - LlmResponse::ToolCall { - thought, - tool_name, - arguments, - } => { - // Add assistant's reasoning - messages.push(Message::assistant(format!( - "THOUGHT: {}\nACTION: {}\nACTION_INPUT: {}", - thought, - tool_name, - serde_json::to_string_pretty(&arguments).unwrap_or_default() - ))); - - // Execute the tool - let result = self.execute_tool(&tool_name, arguments).await?; - - // Add observation - messages.push(Message::tool( - tool_name.clone(), - format!( - "OBSERVATION: {}", - serde_json::to_string_pretty(&result.output).unwrap_or_default() - ), - )); - } - LlmResponse::FinalAnswer { thought, answer } => { - messages.push(Message::assistant(format!( - "THOUGHT: {}\nFINAL_ANSWER: {}", - thought, answer - ))); - return Ok(AgentResult { - answer, - iterations: iteration + 1, - messages, - success: true, - }); - } - LlmResponse::Reasoning { thought } => { - messages.push(Message::assistant(format!("THOUGHT: {}", thought))); - } - } - } - - // Max iterations reached - Ok(AgentResult { - answer: "Maximum iterations reached without finding a final answer".to_string(), - iterations: self.config.max_iterations, - messages, - success: false, - }) - } - - /// Discover available tools from the MCP client - async fn discover_tools(&self) -> Result> { - self.tool_client.list_tools().await - } - - /// Build a ReAct-formatted prompt with available tools - fn build_react_prompt( - &self, - messages: &[Message], - tools: &[McpToolDescriptor], - ) -> Vec { - let mut prompt_messages = Vec::new(); - - // System prompt with ReAct instructions - let system_prompt = self.build_system_prompt(tools); - prompt_messages.push(Message::system(system_prompt)); - - // Add conversation history - prompt_messages.extend_from_slice(messages); - - prompt_messages - } - - /// Build the system prompt with ReAct format and tool descriptions - fn build_system_prompt(&self, tools: &[McpToolDescriptor]) -> String { - if let Some(custom) = &self.config.system_prompt { - return assemble_prompt_with_tools_and_subagents( - custom, - tools, - &self.config.sub_agents, - ); - } - - let mut prompt = String::from( - "You are an AI assistant that uses the ReAct (Reasoning and Acting) pattern to solve tasks.\n\n\ - You have access to the following tools:\n\n", - ); - - for tool in tools { - prompt.push_str(&format!("- {}: {}\n", tool.name, tool.description)); - } - - prompt.push_str( - "\nUse the following format:\n\n\ - THOUGHT: Your reasoning about what to do next\n\ - ACTION: tool_name\n\ - ACTION_INPUT: {\"param\": \"value\"}\n\n\ - You will receive:\n\ - OBSERVATION: The result of the tool execution\n\n\ - Continue this process until you have enough information, then provide:\n\ - THOUGHT: Final reasoning\n\ - FINAL_ANSWER: Your comprehensive answer\n\n\ - Important:\n\ - - Always start with THOUGHT to explain your reasoning\n\ - - ACTION must be one of the available tools\n\ - - ACTION_INPUT must be valid JSON\n\ - - Use FINAL_ANSWER only when you have sufficient information\n", - ); - - append_subagent_guidance(&mut prompt, &self.config.sub_agents); - - prompt - } - - /// Generate an LLM response - async fn generate_llm_response(&self, messages: Vec) -> Result { - let request = ChatRequest { - model: self.config.model.clone(), - messages, - parameters: ChatParameters { - temperature: self.config.temperature, - max_tokens: self.config.max_tokens, - stream: false, - ..Default::default() - }, - tools: None, - }; - - let response = self.llm_client.send_prompt(request).await?; - Ok(response.message.content) - } - /// Parse LLM response into structured format - pub fn parse_response(&self, text: &str) -> Result { - let lines: Vec<&str> = text.lines().collect(); - let mut thought = String::new(); - let mut action = String::new(); - let mut action_input = String::new(); - let mut final_answer = String::new(); - - let mut i = 0; - while i < lines.len() { - let line = lines[i].trim(); - - if line.starts_with("THOUGHT:") { - thought = line - .strip_prefix("THOUGHT:") - .unwrap_or("") - .trim() - .to_string(); - // Collect multi-line thoughts - i += 1; - while i < lines.len() - && !lines[i].trim().starts_with("ACTION") - && !lines[i].trim().starts_with("FINAL_ANSWER") - { - if !lines[i].trim().is_empty() { - thought.push(' '); - thought.push_str(lines[i].trim()); - } - i += 1; - } - continue; - } - - if line.starts_with("ACTION:") { - action = line - .strip_prefix("ACTION:") - .unwrap_or("") - .trim() - .to_string(); - i += 1; - continue; - } - - if line.starts_with("ACTION_INPUT:") { - action_input = line - .strip_prefix("ACTION_INPUT:") - .unwrap_or("") - .trim() - .to_string(); - // Collect multi-line JSON - i += 1; - while i < lines.len() - && !lines[i].trim().starts_with("THOUGHT") - && !lines[i].trim().starts_with("ACTION") - { - action_input.push(' '); - action_input.push_str(lines[i].trim()); - i += 1; - } - continue; - } - - if line.starts_with("FINAL_ANSWER:") { - final_answer = line - .strip_prefix("FINAL_ANSWER:") - .unwrap_or("") - .trim() - .to_string(); - // Collect multi-line answer - i += 1; - while i < lines.len() { - if !lines[i].trim().is_empty() { - final_answer.push(' '); - final_answer.push_str(lines[i].trim()); - } - i += 1; - } - break; - } - - i += 1; - } - - // Determine response type - if !final_answer.is_empty() { - return Ok(LlmResponse::FinalAnswer { - thought, - answer: final_answer, - }); - } - - if !action.is_empty() { - let arguments = if action_input.is_empty() { - serde_json::json!({}) - } else { - serde_json::from_str(&action_input) - .map_err(|e| Error::Agent(ParseError::InvalidJson(e.to_string()).to_string()))? - }; - - return Ok(LlmResponse::ToolCall { - thought, - tool_name: action, - arguments, - }); - } - - if !thought.is_empty() { - return Ok(LlmResponse::Reasoning { thought }); - } - - Err(Error::Agent(ParseError::NoPattern.to_string())) - } - - /// Execute a tool call - async fn execute_tool( - &self, - tool_name: &str, - arguments: serde_json::Value, - ) -> Result { - let call = McpToolCall { - name: tool_name.to_string(), - arguments, - }; - self.tool_client.call_tool(call).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::llm::test_utils::MockProvider; - use crate::mcp::test_utils::MockMcpClient; - use crate::tools::WEB_SEARCH_TOOL_NAME; - - #[test] - fn test_parse_tool_call() { - let executor = AgentExecutor { - llm_client: Arc::new(MockProvider::default()), - tool_client: Arc::new(MockMcpClient), - config: AgentConfig::default(), - }; - - let text = r#" -THOUGHT: I need to search for information about Rust -ACTION: web_search -ACTION_INPUT: {"query": "Rust programming language"} - "#; - - let result = executor.parse_response(text).unwrap(); - match result { - LlmResponse::ToolCall { - thought, - tool_name, - arguments, - } => { - assert!(thought.contains("search for information")); - assert!(matches!(tool_name.as_str(), WEB_SEARCH_TOOL_NAME)); - assert_eq!(arguments["query"], "Rust programming language"); - } - _ => panic!("Expected ToolCall"), - } - } - - #[test] - fn test_parse_final_answer() { - let executor = AgentExecutor { - llm_client: Arc::new(MockProvider::default()), - tool_client: Arc::new(MockMcpClient), - config: AgentConfig::default(), - }; - - let text = r#" -THOUGHT: I now have enough information to answer -FINAL_ANSWER: Rust is a systems programming language focused on safety and performance. - "#; - - let result = executor.parse_response(text).unwrap(); - match result { - LlmResponse::FinalAnswer { thought, answer } => { - assert!(thought.contains("enough information")); - assert!(answer.contains("Rust is a systems programming language")); - } - _ => panic!("Expected FinalAnswer"), - } - } -} diff --git a/crates/owlen-core/src/agent_registry.rs b/crates/owlen-core/src/agent_registry.rs deleted file mode 100644 index 3628b38..0000000 --- a/crates/owlen-core/src/agent_registry.rs +++ /dev/null @@ -1,462 +0,0 @@ -use crate::{Error, Result}; -use serde::Deserialize; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -/// Maximum allowed size (bytes) for an agent prompt file. -const MAX_PROMPT_SIZE_BYTES: usize = 128 * 1024; - -/// Definition of a sub-agent that can be referenced by the primary agent prompt. -#[derive(Debug, Clone)] -pub struct SubAgentSpec { - pub id: String, - pub name: Option, - pub description: Option, - pub prompt: String, -} - -/// Fully resolved agent profile loaded from configuration files. -#[derive(Debug, Clone)] -pub struct AgentProfile { - pub id: String, - pub name: Option, - pub description: Option, - pub system_prompt: String, - pub model: Option, - pub temperature: Option, - pub max_iterations: Option, - pub max_tokens: Option, - pub tags: Vec, - pub sub_agents: Vec, - pub source_path: PathBuf, -} - -impl AgentProfile { - pub fn display_name(&self) -> &str { - self.name.as_deref().unwrap_or(self.id.as_str()) - } -} - -/// Registry responsible for discovering and loading user-defined agent profiles. -#[derive(Debug, Clone, Default)] -pub struct AgentRegistry { - profiles: Vec, - index: HashMap, - search_paths: Vec, -} - -impl AgentRegistry { - /// Build a registry by discovering configuration in standard locations. - pub fn discover(project_hint: Option<&Path>) -> Result { - let mut search_paths = Vec::new(); - - if let Some(config_dir) = dirs::config_dir() { - search_paths.push(config_dir.join("owlen").join("agents")); - } - - search_paths.extend(discover_project_agent_paths(project_hint)); - - if let Ok(env) = std::env::var("OWLEN_AGENTS_PATH") { - for path in env.split(std::path::MAIN_SEPARATOR) { - if !path.trim().is_empty() { - search_paths.push(PathBuf::from(path)); - } - } - } - - Self::load_from_paths(search_paths) - } - - /// Build the registry from explicit paths. - pub fn load_from_paths(paths: Vec) -> Result { - let mut registry = Self { - profiles: Vec::new(), - index: HashMap::new(), - search_paths: paths.clone(), - }; - - for path in paths { - registry.load_directory(&path)?; - } - - Ok(registry) - } - - /// Return the list of discovered agent profiles. - pub fn profiles(&self) -> &[AgentProfile] { - &self.profiles - } - - /// Return a profile by identifier. - pub fn get(&self, id: &str) -> Option<&AgentProfile> { - self.index.get(id).and_then(|idx| self.profiles.get(*idx)) - } - - /// Reload all search paths, replacing existing profiles. - pub fn reload(&mut self) -> Result<()> { - let paths = self.search_paths.clone(); - self.profiles.clear(); - self.index.clear(); - - for path in paths { - self.load_directory(&path)?; - } - - Ok(()) - } - - fn load_directory(&mut self, dir: &Path) -> Result<()> { - if !dir.exists() { - return Ok(()); - } - - let mut files = Vec::new(); - collect_agent_files(dir, &mut files)?; - files.sort(); - - for file in files { - match load_agent_file(&file) { - Ok(mut profiles) => { - for profile in profiles.drain(..) { - let id = profile.id.clone(); - if let Some(existing) = self.index.get(&id).copied() { - // Later search paths override earlier ones. - self.profiles[existing] = profile; - } else { - let idx = self.profiles.len(); - self.profiles.push(profile); - self.index.insert(id, idx); - } - } - } - Err(err) => { - return Err(Error::Config(format!( - "Failed to load agent definition {}: {err}", - file.display() - ))); - } - } - } - - Ok(()) - } -} - -fn collect_agent_files(dir: &Path, files: &mut Vec) -> Result<()> { - if !dir.exists() { - return Ok(()); - } - - for entry in fs::read_dir(dir).map_err(Error::Io)? { - let entry = entry.map_err(Error::Io)?; - let path = entry.path(); - if path.is_dir() { - collect_agent_files(&path, files)?; - } else if path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("toml")) - .unwrap_or(false) - { - files.push(path); - } - } - - Ok(()) -} - -fn discover_project_agent_paths(project_hint: Option<&Path>) -> Vec { - let mut results = Vec::new(); - - let mut current = project_hint - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()); - - while let Some(path) = current { - let candidate = path.join(".owlen").join("agents"); - if candidate.exists() { - results.push(candidate); - } - - current = path.parent().map(PathBuf::from); - } - - results -} - -fn load_agent_file(path: &Path) -> Result> { - let raw = fs::read_to_string(path).map_err(Error::Io)?; - if raw.trim().is_empty() { - return Ok(Vec::new()); - } - - let document: AgentDocument = toml::from_str(&raw) - .map_err(|err| Error::Config(format!("Unable to parse {}: {err}", path.display())))?; - - let mut profiles = Vec::new(); - - if document.agents.is_empty() { - let single: SingleAgentFile = toml::from_str(&raw).map_err(|err| { - Error::Config(format!( - "Agent definition {} must contain either [[agents]] tables or top-level id/prompt fields: {err}", - path.display() - )) - })?; - profiles.push(resolve_agent_entry(path, &single.entry)?); - return Ok(profiles); - } - - for entry in document.agents { - profiles.push(resolve_agent_entry(path, &entry)?); - } - - Ok(profiles) -} - -fn resolve_agent_entry(path: &Path, entry: &AgentEntry) -> Result { - let base_dir = path - .parent() - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - - let system_prompt = entry - .prompt - .as_ref() - .ok_or_else(|| { - Error::Config(format!( - "Agent '{}' in {} is missing a `prompt` value", - entry.id, - path.display() - )) - })? - .resolve(&base_dir)?; - - let mut sub_agents = Vec::new(); - for (id, sub) in &entry.sub_agents { - let prompt = sub.prompt.resolve(&base_dir)?; - sub_agents.push(SubAgentSpec { - id: id.clone(), - name: sub.name.clone(), - description: sub.description.clone(), - prompt, - }); - } - - Ok(AgentProfile { - id: entry.id.clone(), - name: entry.name.clone(), - description: entry.description.clone(), - system_prompt, - model: entry.parameters.as_ref().and_then(|p| p.model.clone()), - temperature: entry.parameters.as_ref().and_then(|p| p.temperature), - max_iterations: entry.parameters.as_ref().and_then(|p| p.max_iterations), - max_tokens: entry.parameters.as_ref().and_then(|p| p.max_tokens), - tags: entry.tags.clone().unwrap_or_default(), - sub_agents, - source_path: path.to_path_buf(), - }) -} - -#[derive(Debug, Deserialize)] -struct AgentDocument { - #[serde(default = "default_schema_version")] - _version: String, - #[serde(default)] - agents: Vec, -} - -#[derive(Debug, Deserialize)] -struct SingleAgentFile { - #[serde(default = "default_schema_version")] - _version: String, - #[serde(flatten)] - entry: AgentEntry, -} - -fn default_schema_version() -> String { - "1".to_string() -} - -#[derive(Debug, Deserialize)] -struct AgentEntry { - id: String, - #[serde(default)] - name: Option, - #[serde(default)] - description: Option, - #[serde(default)] - tags: Option>, - #[serde(default)] - prompt: Option, - #[serde(default)] - parameters: Option, - #[serde(default)] - sub_agents: HashMap, -} - -#[derive(Debug, Deserialize)] -struct AgentParameters { - #[serde(default)] - model: Option, - #[serde(default)] - temperature: Option, - #[serde(default)] - max_iterations: Option, - #[serde(default)] - max_tokens: Option, -} - -#[derive(Debug, Deserialize)] -struct SubAgentEntry { - #[serde(default)] - name: Option, - #[serde(default)] - description: Option, - prompt: PromptSpec, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum PromptSpec { - Inline(String), - Source { file: String }, -} - -impl PromptSpec { - fn resolve(&self, base_dir: &Path) -> Result { - match self { - PromptSpec::Inline(value) => Ok(value.trim().to_string()), - PromptSpec::Source { file } => { - let path = if Path::new(file).is_absolute() { - PathBuf::from(file) - } else { - base_dir.join(file) - }; - - let data = fs::read(&path).map_err(Error::Io)?; - if data.len() > MAX_PROMPT_SIZE_BYTES { - return Err(Error::Config(format!( - "Prompt file {} exceeds the maximum supported size ({MAX_PROMPT_SIZE_BYTES} bytes)", - path.display() - ))); - } - - let text = String::from_utf8(data).map_err(|_| { - Error::Config(format!("Prompt file {} is not valid UTF-8", path.display())) - })?; - - Ok(text.trim().to_string()) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::tempdir; - - #[test] - fn load_simple_agent() { - let dir = tempdir().expect("temp dir"); - let agent_dir = dir.path().join("agents"); - fs::create_dir_all(&agent_dir).unwrap(); - - let mut file = fs::File::create(agent_dir.join("support.toml")).unwrap(); - writeln!( - file, - r#" -version = "1" - -[[agents]] -id = "support" -name = "Support Specialist" -description = "Handles user support tickets." -prompt = "You are a helpful support assistant." - - [agents.parameters] - model = "gpt-4" - max_iterations = 8 - temperature = 0.2 - - [agents.sub_agents.first_line] - name = "First-line support" - description = "Handles simple issues" - prompt = "Escalate complex issues." -"# - ) - .unwrap(); - - let registry = AgentRegistry::load_from_paths(vec![agent_dir]).unwrap(); - assert_eq!(registry.profiles.len(), 1); - - let profile = registry.get("support").unwrap(); - assert_eq!(profile.display_name(), "Support Specialist"); - assert_eq!( - profile.system_prompt, - "You are a helpful support assistant." - ); - assert_eq!(profile.model.as_deref(), Some("gpt-4")); - assert_eq!(profile.max_iterations, Some(8)); - assert_eq!(profile.sub_agents.len(), 1); - assert_eq!(profile.sub_agents[0].id, "first_line"); - } - - #[test] - fn prompt_from_file_resolves_relative_path() { - let dir = tempdir().expect("temp dir"); - let agent_dir = dir.path().join(".owlen").join("agents"); - let prompt_dir = agent_dir.join("prompts"); - fs::create_dir_all(&prompt_dir).unwrap(); - - fs::write( - prompt_dir.join("researcher.md"), - "Research the latest documentation updates.", - ) - .unwrap(); - - fs::write( - agent_dir.join("doc.toml"), - r#" -version = "1" - -[[agents]] -id = "docs" -prompt = { file = "prompts/researcher.md" } -"#, - ) - .unwrap(); - - let registry = AgentRegistry::load_from_paths(vec![agent_dir]).unwrap(); - let profile = registry.get("docs").unwrap(); - assert_eq!( - profile.system_prompt, - "Research the latest documentation updates." - ); - } - - #[test] - fn load_agent_from_flat_document() { - let dir = tempdir().expect("temp dir"); - let agent_dir = dir.path().join("agents"); - fs::create_dir_all(&agent_dir).unwrap(); - - fs::write( - agent_dir.join("flat.toml"), - r#" -version = "1" -id = "flat" -name = "Flat Agent" -prompt = "Operate using flat configuration." -"#, - ) - .unwrap(); - - let registry = AgentRegistry::load_from_paths(vec![agent_dir]).unwrap(); - let profile = registry.get("flat").expect("profile present"); - assert_eq!(profile.display_name(), "Flat Agent"); - assert_eq!(profile.system_prompt, "Operate using flat configuration."); - } -} diff --git a/crates/owlen-core/src/automation/mod.rs b/crates/owlen-core/src/automation/mod.rs deleted file mode 100644 index efe7b15..0000000 --- a/crates/owlen-core/src/automation/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! High-level automation APIs for repository workflows (commit templating, PR review, etc.). - -pub mod repo; - -pub use repo::{ - CommitTemplate, CommitTemplateSection, DiffCaptureMode, DiffStatistics, FileChange, - PullRequestContext, PullRequestReview, RepoAutomation, ReviewChecklistItem, ReviewFinding, - ReviewSeverity, WorkflowStep, -}; diff --git a/crates/owlen-core/src/automation/repo.rs b/crates/owlen-core/src/automation/repo.rs deleted file mode 100644 index 472c310..0000000 --- a/crates/owlen-core/src/automation/repo.rs +++ /dev/null @@ -1,943 +0,0 @@ -use crate::{Error, Result}; -use serde::{Deserialize, Serialize}; -use std::fmt; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::str; - -/// Controls which diff snapshot should be inspected. -#[derive(Debug, Clone, Copy)] -pub enum DiffCaptureMode<'a> { - /// Inspect staged changes (`git diff --cached`). - Staged, - /// Inspect unstaged working-tree changes (`git diff`). - WorkingTree, - /// Inspect the diff between two refs. - Range { base: &'a str, head: &'a str }, -} - -/// High-level automation entry-point for repository-centric workflows. -pub struct RepoAutomation { - repo_root: PathBuf, -} - -impl RepoAutomation { - /// Discover the git repository root starting from the provided path. - pub fn from_path(path: impl AsRef) -> Result { - let root = discover_repo_root(path.as_ref())?; - Ok(Self { repo_root: root }) - } - - /// Return the repository root on disk. - pub fn repo_root(&self) -> &Path { - &self.repo_root - } - - /// Generate a conventional commit template from the selected diff snapshot. - pub fn generate_commit_template(&self, mode: DiffCaptureMode<'_>) -> Result { - let diff = capture_diff(&self.repo_root, mode)?; - if diff.trim().is_empty() { - return Err(Error::InvalidInput( - "No changes detected for the selected diff snapshot.".to_string(), - )); - } - Ok(CommitTemplate::from_diff(&diff)) - } - - /// Produce a pull-request style review for the given range of commits. - pub fn generate_pr_review( - &self, - base: Option<&str>, - head: Option<&str>, - ) -> Result { - let head = head.unwrap_or("HEAD"); - let base = base.unwrap_or("origin/main"); - let merge_base = resolve_merge_base(&self.repo_root, base, head)?; - let diff = capture_range_diff(&self.repo_root, &merge_base, head)?; - if diff.trim().is_empty() { - return Err(Error::InvalidInput( - "The computed diff between the selected refs is empty.".to_string(), - )); - } - let stats = DiffStatistics::from_diff(&diff); - let context = PullRequestContext { - title: format!("Diff of {head} vs {base}"), - body: None, - author: None, - base_branch: base.to_string(), - head_branch: head.to_string(), - additions: stats.additions as u64, - deletions: stats.deletions as u64, - changed_files: stats.files as u64, - html_url: None, - }; - Ok(PullRequestReview::from_diff(context, &diff)) - } -} - -/// Summarised information about a changed file. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileChange { - pub old_path: String, - pub new_path: String, - pub change: ChangeKind, - pub additions: usize, - pub deletions: usize, -} - -impl FileChange { - pub fn primary_path(&self) -> &str { - if !self.new_path.is_empty() { - &self.new_path - } else if !self.old_path.is_empty() { - &self.old_path - } else { - "" - } - } - - pub fn is_test(&self) -> bool { - FILE_TEST_HINTS - .iter() - .any(|hint| self.primary_path().contains(hint)) - } - - pub fn is_doc(&self) -> bool { - DOC_EXTENSIONS - .iter() - .any(|ext| self.primary_path().ends_with(ext)) - || self.primary_path().starts_with("docs/") - } - - pub fn is_config(&self) -> bool { - CONFIG_EXTENSIONS - .iter() - .any(|ext| self.primary_path().ends_with(ext)) - } - - pub fn is_code(&self) -> bool { - !self.is_doc() && !self.is_config() - } -} - -/// Change classification for a diff entry. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChangeKind { - Added, - Removed, - Modified, - Renamed { from: String }, -} - -/// Structured conventional commit template recommendation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommitTemplate { - pub prefix: String, - pub summary: Vec, - pub sections: Vec, - pub workflow: Vec, -} - -impl CommitTemplate { - pub fn from_diff(diff: &str) -> Self { - let changes = parse_file_changes(diff); - let metrics = DiffMetrics::from_changes(&changes); - let prefix = select_conventional_prefix(&metrics); - let summary = changes.iter().map(format_change_summary).collect(); - let sections = build_commit_sections(&metrics); - let workflow = vec![ - WorkflowStep::new( - "Parse diff", - format!("Identified {} files", metrics.changed_files), - ), - WorkflowStep::new( - "Choose conventional prefix", - format!("Selected `{}` based on touched domains", prefix), - ), - WorkflowStep::new("Assemble testing checklist", testing_summary(§ions)), - ]; - - Self { - prefix: prefix.to_string(), - summary, - sections, - workflow, - } - } - - /// Render the template as Markdown. - pub fn render_markdown(&self) -> String { - let mut out = String::new(); - out.push_str(&format!("{} \n\n", self.prefix)); - if !self.summary.is_empty() { - out.push_str("Summary:\n"); - for line in &self.summary { - out.push_str("- "); - out.push_str(line); - out.push('\n'); - } - out.push('\n'); - } - - for section in &self.sections { - out.push_str(&format!("{}:\n", section.title)); - for line in §ion.lines { - out.push_str("- "); - out.push_str(line); - out.push('\n'); - } - out.push('\n'); - } - - out.trim_end().to_string() - } -} - -/// A named block of checklist items in the commit template. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommitTemplateSection { - pub title: String, - pub lines: Vec, -} - -/// Metadata about a pull request / change range. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PullRequestContext { - pub title: String, - pub body: Option, - pub author: Option, - pub base_branch: String, - pub head_branch: String, - pub additions: u64, - pub deletions: u64, - pub changed_files: u64, - pub html_url: Option, -} - -/// Markdown-ready automation review artifact. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PullRequestReview { - pub context: PullRequestContext, - pub summary: String, - pub highlights: Vec, - pub findings: Vec, - pub checklist: Vec, - pub workflow: Vec, -} - -impl PullRequestReview { - pub fn from_diff(context: PullRequestContext, diff: &str) -> Self { - let changes = parse_file_changes(diff); - let metrics = DiffMetrics::from_changes(&changes); - let highlights = build_highlights(&changes, &metrics); - let findings = analyze_findings(diff, &changes, &metrics); - let checklist = build_review_checklist(&metrics, &changes); - let summary = format!( - "{} files touched (+{}, -{}) · base {} → head {}", - metrics.changed_files, - metrics.total_additions, - metrics.total_deletions, - context.base_branch, - context.head_branch - ); - let workflow = vec![ - WorkflowStep::new( - "Collect diff metadata", - format!( - "{} files, {} additions, {} deletions", - metrics.changed_files, metrics.total_additions, metrics.total_deletions - ), - ), - WorkflowStep::new( - "Assess risk", - format!( - "{} potential issues detected", - findings - .iter() - .filter(|finding| finding.severity != ReviewSeverity::Info) - .count() - ), - ), - WorkflowStep::new( - "Prepare checklist", - format!("{} follow-up items surfaced", checklist.len()), - ), - ]; - - Self { - context, - summary, - highlights, - findings, - checklist, - workflow, - } - } - - /// Render a Markdown review body with sections for highlights, findings, and checklists. - pub fn render_markdown(&self) -> String { - let mut out = String::new(); - out.push_str(&format!("### Summary\n{}\n\n", self.summary)); - - if !self.highlights.is_empty() { - out.push_str("### Highlights\n"); - for highlight in &self.highlights { - out.push_str("- "); - out.push_str(highlight); - out.push('\n'); - } - out.push('\n'); - } - - if !self.findings.is_empty() { - out.push_str("### Findings\n"); - for finding in &self.findings { - out.push_str(&format!( - "- **{}**: {}\n", - finding.severity.label(), - finding.message - )); - if !finding.locations.is_empty() { - for loc in &finding.locations { - out.push_str(" - "); - out.push_str(loc); - out.push('\n'); - } - } - } - out.push('\n'); - } - - if !self.checklist.is_empty() { - out.push_str("### Checklist\n"); - for item in &self.checklist { - let box_mark = if item.completed { "[x]" } else { "[ ]" }; - out.push_str(&format!("- {} {}\n", box_mark, item.label)); - } - out.push('\n'); - } - - out.trim_end().to_string() - } -} - -/// Individual review finding surfaced during heuristics. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReviewFinding { - pub severity: ReviewSeverity, - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub locations: Vec, -} - -impl ReviewFinding { - fn new(severity: ReviewSeverity, message: impl Into) -> Self { - Self { - severity, - message: message.into(), - locations: Vec::new(), - } - } - - fn with_location(mut self, location: impl Into) -> Self { - self.locations.push(location.into()); - self - } -} - -/// Severity classification for review findings. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum ReviewSeverity { - Info, - Low, - Medium, - High, -} - -impl ReviewSeverity { - pub fn label(&self) -> &'static str { - match self { - ReviewSeverity::Info => "info", - ReviewSeverity::Low => "low", - ReviewSeverity::Medium => "medium", - ReviewSeverity::High => "high", - } - } -} - -impl fmt::Display for ReviewSeverity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.label()) - } -} - -/// Checklist item exposed in reviews. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReviewChecklistItem { - pub label: String, - pub completed: bool, -} - -impl ReviewChecklistItem { - fn new(label: impl Into, completed: bool) -> Self { - Self { - label: label.into(), - completed, - } - } -} - -/// High-level workflow steps surfaced for SDK-style automation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkflowStep { - pub label: String, - pub outcome: String, -} - -impl WorkflowStep { - pub fn new(label: impl Into, outcome: impl Into) -> Self { - Self { - label: label.into(), - outcome: outcome.into(), - } - } -} - -/// Aggregate diff metrics derived from parsed file changes. -#[derive(Debug, Default, Clone)] -struct DiffMetrics { - changed_files: usize, - total_additions: usize, - total_deletions: usize, - test_files: usize, - doc_files: usize, - config_files: usize, - code_files: usize, -} - -/// Summary statistics extracted from a diff. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiffStatistics { - pub files: usize, - pub additions: usize, - pub deletions: usize, -} - -impl DiffStatistics { - pub fn from_diff(diff: &str) -> Self { - Self { - files: count_files(diff), - additions: count_symbol(diff, '+'), - deletions: count_symbol(diff, '-'), - } - } -} - -/// Convenience helper that converts a diff into aggregate statistics. -pub fn summarize_diff(diff: &str) -> DiffStatistics { - DiffStatistics::from_diff(diff) -} - -impl DiffMetrics { - fn from_changes(changes: &[FileChange]) -> Self { - let mut metrics = DiffMetrics { - changed_files: changes.len(), - ..DiffMetrics::default() - }; - for change in changes { - metrics.total_additions += change.additions; - metrics.total_deletions += change.deletions; - if change.is_test() { - metrics.test_files += 1; - } else if change.is_doc() { - metrics.doc_files += 1; - } else if change.is_config() { - metrics.config_files += 1; - } else { - metrics.code_files += 1; - } - } - metrics - } - - fn has_tests(&self) -> bool { - self.test_files > 0 - } - - fn has_docs(&self) -> bool { - self.doc_files > 0 - } -} - -// ------------------------------------------------------------------------------------------------- -// Diff parsing and heuristics -// ------------------------------------------------------------------------------------------------- - -fn parse_file_changes(diff: &str) -> Vec { - let mut changes = Vec::new(); - let mut current: Option = None; - - for line in diff.lines() { - if line.starts_with("diff --git ") { - if let Some(change) = current.take() { - changes.push(change); - } - let mut parts = line.split_whitespace().skip(2); - let old = parts.next().unwrap_or("a/unknown"); - let new = parts.next().unwrap_or("b/unknown"); - current = Some(FileChange { - old_path: strip_path_prefix(old, "a/"), - new_path: strip_path_prefix(new, "b/"), - change: ChangeKind::Modified, - additions: 0, - deletions: 0, - }); - } else if let Some(change) = current.as_mut() { - if line.starts_with("new file mode") { - change.change = ChangeKind::Added; - } else if line.starts_with("deleted file mode") { - change.change = ChangeKind::Removed; - } else if line.starts_with("rename from ") { - let from = line.trim_start_matches("rename from ").trim(); - change.change = ChangeKind::Renamed { - from: strip_path_prefix(from, ""), - }; - change.old_path = strip_path_prefix(from, ""); - } else if line.starts_with("rename to ") { - let to = line.trim_start_matches("rename to ").trim(); - change.new_path = strip_path_prefix(to, ""); - } else if line.starts_with("--- ") { - let old = line.trim_start_matches("--- ").trim(); - if old.starts_with('a') { - change.old_path = strip_path_prefix(old, "a/"); - } - } else if line.starts_with("+++ ") { - let new = line.trim_start_matches("+++ ").trim(); - if new.starts_with('b') { - change.new_path = strip_path_prefix(new, "b/"); - } - } else if line.starts_with('+') && !line.starts_with("+++") { - change.additions += 1; - } else if line.starts_with('-') && !line.starts_with("---") { - change.deletions += 1; - } - } - } - - if let Some(change) = current.take() { - changes.push(change); - } - - changes -} - -fn format_change_summary(change: &FileChange) -> String { - match &change.change { - ChangeKind::Added => format!("add {} (+{})", change.primary_path(), change.additions), - ChangeKind::Removed => format!("remove {} (-{})", change.primary_path(), change.deletions), - ChangeKind::Renamed { from } => format!( - "rename {} → {} (+{}, -{})", - from, - change.primary_path(), - change.additions, - change.deletions - ), - ChangeKind::Modified => format!( - "update {} (+{}, -{})", - change.primary_path(), - change.additions, - change.deletions - ), - } -} - -fn select_conventional_prefix(metrics: &DiffMetrics) -> &'static str { - if metrics.changed_files == 0 { - return "chore:"; - } - if metrics.doc_files > 0 && metrics.code_files == 0 && metrics.test_files == 0 { - "docs:" - } else if metrics.test_files > 0 && metrics.code_files == 0 && metrics.doc_files == 0 { - "test:" - } else if metrics.config_files > 0 && metrics.code_files == 0 { - "chore:" - } else if metrics.total_deletions > metrics.total_additions - && metrics.doc_files == 0 - && metrics.test_files == 0 - { - "refactor:" - } else { - "feat:" - } -} - -fn build_commit_sections(metrics: &DiffMetrics) -> Vec { - let mut sections = Vec::new(); - let tests_label = if metrics.has_tests() { "[x]" } else { "[ ]" }; - let docs_label = if metrics.has_docs() { "[x]" } else { "[ ]" }; - - sections.push(CommitTemplateSection { - title: "Testing".to_string(), - lines: vec![ - format!("{} unit tests", tests_label), - format!("{} integration / e2e", tests_label), - "[ ] lint / fmt".to_string(), - ], - }); - - sections.push(CommitTemplateSection { - title: "Documentation".to_string(), - lines: vec![ - format!("{} docs updated", docs_label), - "[ ] release notes".to_string(), - ], - }); - - sections -} - -fn testing_summary(sections: &[CommitTemplateSection]) -> String { - sections - .iter() - .flat_map(|section| §ion.lines) - .filter(|line| line.contains("tests")) - .cloned() - .collect::>() - .join(", ") -} - -fn build_highlights(changes: &[FileChange], metrics: &DiffMetrics) -> Vec { - let mut highlights = Vec::new(); - if metrics.code_files > 0 { - highlights.push(format!( - "{} code files modified (+{}, -{})", - metrics.code_files, metrics.total_additions, metrics.total_deletions - )); - } - if metrics.has_tests() { - highlights.push(format!("{} test files updated", metrics.test_files)); - } else if metrics.code_files > 0 { - highlights.push("No test files updated; consider adding coverage.".to_string()); - } - if metrics.has_docs() { - highlights.push(format!("{} documentation files updated", metrics.doc_files)); - } - - for change in changes - .iter() - .filter(|change| change.additions + change.deletions > 400) - { - highlights.push(format!( - "Large change in {} ({:+} / {:-})", - change.primary_path(), - change.additions, - change.deletions - )); - } - - highlights -} - -fn analyze_findings( - diff: &str, - changes: &[FileChange], - metrics: &DiffMetrics, -) -> Vec { - let mut findings = Vec::new(); - if !metrics.has_tests() && metrics.code_files > 0 { - findings.push( - ReviewFinding::new( - ReviewSeverity::Medium, - "Code changes detected without accompanying tests.", - ) - .with_location("Consider adding unit or integration coverage."), - ); - } - - let mut risky_locations: Vec = Vec::new(); - for change in changes.iter().filter(|change| change.is_code()) { - if change.additions > 0 && change.deletions == 0 && change.additions > 200 { - findings.push( - ReviewFinding::new( - ReviewSeverity::Low, - format!("Large addition in {}", change.primary_path()), - ) - .with_location(format!("{} lines added", change.additions)), - ); - } - } - - for line in diff.lines() { - if line.starts_with('+') { - if line.contains("unwrap(") || line.contains(".expect(") { - risky_locations.push(line.trim_start_matches('+').trim().to_string()); - } else if line.contains("unsafe ") { - findings.push( - ReviewFinding::new( - ReviewSeverity::High, - "Usage of `unsafe` detected; ensure invariants are documented.", - ) - .with_location(line.trim()), - ); - } else if line.contains("todo!") || line.contains("unimplemented!") { - findings.push( - ReviewFinding::new( - ReviewSeverity::Medium, - "TODO/unimplemented marker introduced.", - ) - .with_location(line.trim()), - ); - } - } - } - - if !risky_locations.is_empty() { - findings.push(ReviewFinding { - severity: ReviewSeverity::Low, - message: "New unwrap()/expect() calls introduced; confirm they are infallible." - .to_string(), - locations: risky_locations, - }); - } - - findings -} - -fn build_review_checklist( - metrics: &DiffMetrics, - changes: &[FileChange], -) -> Vec { - let mut checklist = Vec::new(); - checklist.push(ReviewChecklistItem::new( - "Tests cover the change surface", - metrics.has_tests(), - )); - checklist.push(ReviewChecklistItem::new( - "Documentation updated if behaviour changed", - metrics.has_docs(), - )); - - let includes_release_artifacts = changes.iter().any(|change| { - let path = change.primary_path(); - path.ends_with("CHANGELOG.md") || path.contains("release") - }); - if !includes_release_artifacts { - checklist.push(ReviewChecklistItem::new( - "Changelog or release notes updated if required", - false, - )); - } - - checklist -} - -// ------------------------------------------------------------------------------------------------- -// Git helpers -// ------------------------------------------------------------------------------------------------- - -fn discover_repo_root(path: &Path) -> Result { - let output = Command::new("git") - .arg("rev-parse") - .arg("--show-toplevel") - .current_dir(path) - .output()?; - if !output.status.success() { - return Err(Error::InvalidInput( - "The current directory is not inside a git repository.".to_string(), - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(PathBuf::from(stdout.trim())) -} - -fn capture_diff(root: &Path, mode: DiffCaptureMode<'_>) -> Result { - let mut cmd = Command::new("git"); - cmd.current_dir(root); - match mode { - DiffCaptureMode::Staged => { - cmd.args(["diff", "--cached", "--unified=3", "--no-color"]); - } - DiffCaptureMode::WorkingTree => { - cmd.args(["diff", "--unified=3", "--no-color"]); - } - DiffCaptureMode::Range { base, head } => { - cmd.args([ - "diff", - "--unified=3", - "--no-color", - &format!("{base}..{head}"), - ]); - } - } - let output = cmd.output()?; - if !output.status.success() { - return Err(Error::Unknown(format!( - "git diff exited with status {}", - output.status - ))); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -fn capture_range_diff(root: &Path, base: &str, head: &str) -> Result { - let output = Command::new("git") - .args([ - "diff", - "--unified=3", - "--no-color", - &format!("{base}..{head}"), - ]) - .current_dir(root) - .output()?; - if !output.status.success() { - return Err(Error::Unknown(format!( - "git diff exited with status {}", - output.status - ))); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -fn resolve_merge_base(root: &Path, base: &str, head: &str) -> Result { - let output = Command::new("git") - .args(["merge-base", base, head]) - .current_dir(root) - .output()?; - if !output.status.success() { - return Err(Error::Unknown(format!( - "git merge-base exited with status {}", - output.status - ))); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -// ------------------------------------------------------------------------------------------------- -// Utility helpers -// ------------------------------------------------------------------------------------------------- - -fn strip_path_prefix(value: &str, prefix: &str) -> String { - value - .trim() - .trim_matches('"') - .trim_start_matches(prefix) - .trim_start_matches("./") - .to_string() -} - -fn count_symbol(diff: &str, symbol: char) -> usize { - diff.lines() - .filter(|line| { - line.starts_with(symbol) - && !matches!( - (symbol, line.chars().nth(1), line.chars().nth(2)), - ('+', Some('+'), Some('+')) | ('-', Some('-'), Some('-')) - ) - }) - .count() -} - -fn count_files(diff: &str) -> usize { - diff.lines() - .filter(|line| line.starts_with("diff --git")) - .count() -} - -static FILE_TEST_HINTS: [&str; 5] = ["tests/", "_test.", "test/", "spec/", "fixtures/"]; -static DOC_EXTENSIONS: [&str; 6] = [".md", ".rst", ".adoc", ".txt", ".mdx", ".markdown"]; -static CONFIG_EXTENSIONS: [&str; 6] = [".toml", ".yaml", ".yml", ".json", ".ini", ".conf"]; - -// ------------------------------------------------------------------------------------------------- -// Tests -// ------------------------------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE_DIFF: &str = r#"diff --git a/src/foo.rs b/src/foo.rs -index e69de29..4b825dc 100644 ---- a/src/foo.rs -+++ b/src/foo.rs -@@ -+pub fn add(left: i32, right: i32) -> i32 { -+ left + right -+} -diff --git a/tests/foo_test.rs b/tests/foo_test.rs -new file mode 100644 -index 0000000..bf3b82c ---- /dev/null -+++ b/tests/foo_test.rs -@@ -+#[test] -+fn add_adds_numbers() { -+ assert_eq!(crate::add(2, 2), 4); -+} -diff --git a/README.md b/README.md -index 4b825dc..c8f2615 100644 ---- a/README.md -+++ b/README.md -@@ --# Owlen -+# Owlen -+Updated docs -"#; - - #[test] - fn commit_template_infers_prefix_and_sections() { - let template = CommitTemplate::from_diff(SAMPLE_DIFF); - assert_eq!(template.prefix, "feat:"); - assert_eq!(template.summary.len(), 3); - assert_eq!(template.sections.len(), 2); - let tests_section = template - .sections - .iter() - .find(|section| section.title == "Testing") - .expect("testing section"); - assert!(tests_section.lines.iter().any(|line| line.contains("[x]"))); - let markdown = template.render_markdown(); - assert!(markdown.contains("Summary:")); - assert!(markdown.contains("tests")); - } - - #[test] - fn review_highlights_tests_gap() { - let diff = r#"diff --git a/src/lib.rs b/src/lib.rs -index e69de29..4b825dc 100644 ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -+pub fn risky() { -+ let value = std::env::var("MISSING").unwrap(); -+ println!("{}", value); -+} -"#; - let context = PullRequestContext { - title: "Test PR".to_string(), - body: None, - author: Some("demo".to_string()), - base_branch: "main".to_string(), - head_branch: "feature".to_string(), - additions: 3, - deletions: 0, - changed_files: 1, - html_url: None, - }; - let review = PullRequestReview::from_diff(context, diff); - assert!( - review - .findings - .iter() - .any(|finding| finding.severity == ReviewSeverity::Medium) - ); - assert!( - review - .findings - .iter() - .any(|finding| finding.message.contains("unwrap")) - ); - let markdown = review.render_markdown(); - assert!(markdown.contains("Summary")); - assert!(markdown.contains("Checklist")); - } -} diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs deleted file mode 100644 index 75a005d..0000000 --- a/crates/owlen-core/src/config.rs +++ /dev/null @@ -1,2944 +0,0 @@ -use crate::Error; -use crate::ProviderConfig; -use crate::Result; -use crate::mode::ModeConfig; -use crate::tools::WEB_SEARCH_TOOL_NAME; -use crate::ui::RoleLabelDisplay; -use serde::de::{self, Deserializer, Visitor}; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use std::fmt; -use std::fs; -use std::path::{Path, PathBuf}; -use std::str::FromStr; -use std::time::Duration; - -/// Default location for the OWLEN configuration file -pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; - -/// Current schema version written to `config.toml`. -pub const CONFIG_SCHEMA_VERSION: &str = "1.9.0"; - -/// Provider config key for forcing Ollama provider mode. -pub const OLLAMA_MODE_KEY: &str = "ollama_mode"; -/// Extra config key storing the preferred Ollama Cloud endpoint. -pub const OLLAMA_CLOUD_ENDPOINT_KEY: &str = "cloud_endpoint"; -/// Canonical Ollama Cloud base URL. -pub const OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com"; -/// Legacy Ollama Cloud base URL (accepted for backward compatibility). -pub const LEGACY_OLLAMA_CLOUD_BASE_URL: &str = "https://api.ollama.com"; -/// Preferred environment variable used for Ollama Cloud authentication. -pub const OLLAMA_API_KEY_ENV: &str = "OLLAMA_API_KEY"; -/// Legacy environment variable accepted for backward compatibility. -pub const LEGACY_OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY"; -/// Legacy environment variable used by earlier Owlen releases. -pub const LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OWLEN_OLLAMA_CLOUD_API_KEY"; -/// Default hourly soft quota for Ollama Cloud usage visualization (tokens). -pub const DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA: u64 = 50_000; -/// Default weekly soft quota for Ollama Cloud usage visualization (tokens). -pub const DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA: u64 = 250_000; -/// Default TTL (seconds) for cached model listings per provider. -pub const DEFAULT_PROVIDER_LIST_TTL_SECS: u64 = 60; -/// Default context window (tokens) assumed when provider metadata is absent. -pub const DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS: u32 = 8_192; -/// Default base URL for local Ollama daemons. -pub const OLLAMA_LOCAL_BASE_URL: &str = "http://localhost:11434"; -/// Default OpenAI API base URL. -pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; -/// Environment variable name used for OpenAI API keys. -pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; -/// Default Anthropic API base URL. -pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1"; -/// Environment variable name used for Anthropic API keys. -pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY"; - -/// Core configuration shared by all OWLEN clients -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - /// Schema version for on-disk configuration files - #[serde(default = "Config::default_schema_version")] - pub schema_version: String, - /// General application settings - pub general: GeneralSettings, - /// MCP (Multi-Client-Provider) settings - #[serde(default)] - pub mcp: McpSettings, - /// Chat-specific behaviour (history compression, etc.) - #[serde(default)] - pub chat: ChatSettings, - /// Provider specific configuration keyed by provider name - #[serde(default)] - pub providers: HashMap, - /// UI preferences that frontends can opt into - #[serde(default)] - pub ui: UiSettings, - /// Storage related options - #[serde(default)] - pub storage: StorageSettings, - /// Input handling preferences - #[serde(default)] - pub input: InputSettings, - /// Privacy controls for tooling and network usage - #[serde(default)] - pub privacy: PrivacySettings, - /// Security controls for sandboxing and resource limits - #[serde(default)] - pub security: SecuritySettings, - /// Per-tool configuration toggles - #[serde(default)] - pub tools: ToolSettings, - /// Mode-specific tool availability configuration - #[serde(default)] - pub modes: ModeConfig, - /// External MCP server definitions - #[serde(default)] - pub mcp_servers: Vec, - /// User-scoped resource definitions - #[serde(default)] - pub mcp_resources: Vec, - /// Resolved MCP servers across scopes (runtime only). - #[serde(skip)] - pub scoped_mcp_servers: Vec, - /// Effective MCP servers after applying precedence rules (runtime only). - #[serde(skip)] - pub effective_mcp_servers: Vec, - /// Resolved MCP resources across scopes (runtime only). - #[serde(skip)] - pub scoped_mcp_resources: Vec, - /// Effective MCP resources after precedence (runtime only). - #[serde(skip)] - pub effective_mcp_resources: Vec, -} - -impl Default for Config { - fn default() -> Self { - let providers = default_provider_configs(); - - Self { - schema_version: Self::default_schema_version(), - general: GeneralSettings::default(), - mcp: McpSettings::default(), - chat: ChatSettings::default(), - providers, - ui: UiSettings::default(), - storage: StorageSettings::default(), - input: InputSettings::default(), - privacy: PrivacySettings::default(), - security: SecuritySettings::default(), - tools: ToolSettings::default(), - modes: ModeConfig::default(), - mcp_servers: Vec::new(), - mcp_resources: Vec::new(), - scoped_mcp_servers: Vec::new(), - effective_mcp_servers: Vec::new(), - scoped_mcp_resources: Vec::new(), - effective_mcp_resources: Vec::new(), - } - } -} - -/// Configuration for an external MCP server process. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct McpServerConfig { - /// Logical name used to reference the server (e.g., "web_search"). - pub name: String, - /// Command to execute (binary or script). - pub command: String, - /// Arguments passed to the command. - #[serde(default)] - pub args: Vec, - /// Transport mechanism, currently only "stdio" is supported. - #[serde(default = "McpServerConfig::default_transport")] - pub transport: String, - /// Optional environment variable map for the process. - #[serde(default)] - pub env: std::collections::HashMap, - /// Optional OAuth configuration for remote servers. - #[serde(default)] - pub oauth: Option, - /// Timeout for RPC operations in seconds. Defaults to 30 seconds if not specified. - /// Different operations may use different timeout values: - /// - initialize: 60s (longer for initial handshake) - /// - tools/list, resources/list: 10s (should be fast) - /// - tools/call: 120s (tool execution can be slow) - /// - default: 30s - #[serde(default)] - pub rpc_timeout_secs: Option, -} - -impl McpServerConfig { - fn default_transport() -> String { - "stdio".to_string() - } -} - -/// OAuth configuration for MCP servers that require delegated authentication. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct McpOAuthConfig { - /// Public client identifier registered with the authorization server. - pub client_id: String, - /// Optional client secret for confidential clients. - #[serde(default)] - pub client_secret: Option, - /// OAuth authorization endpoint (used for web-based flows). - pub authorize_url: String, - /// OAuth token endpoint. - pub token_url: String, - /// Optional device authorization endpoint for device-code flows. - #[serde(default)] - pub device_authorization_url: Option, - /// Optional redirect URL (PKCE / authorization-code flows). - #[serde(default)] - pub redirect_url: Option, - /// Requested OAuth scopes. - #[serde(default)] - pub scopes: Vec, - /// Environment variable name populated with the bearer access token when spawning stdio servers. - #[serde(default)] - pub token_env: Option, - /// Optional HTTP header name for bearer authentication (defaults to "Authorization"). - #[serde(default)] - pub header: Option, - /// Optional prefix prepended to the access token (defaults to "Bearer "). - #[serde(default)] - pub header_prefix: Option, -} - -impl McpOAuthConfig { - pub fn header_name(&self) -> &str { - self.header.as_deref().unwrap_or("Authorization") - } - - pub fn header_prefix(&self) -> &str { - self.header_prefix.as_deref().unwrap_or("Bearer ") - } -} - -/// Scope for MCP server configuration entries. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum McpConfigScope { - /// User-level configuration stored under the user's config directory. - User, - /// Project configuration stored in the repository (e.g. `.mcp.json`). - Project, - /// Local overrides stored alongside the project but excluded from version control. - Local, -} - -impl McpConfigScope { - fn precedence_iter() -> impl Iterator { - [ - McpConfigScope::Local, - McpConfigScope::Project, - McpConfigScope::User, - ] - .into_iter() - } - - fn as_str(self) -> &'static str { - match self { - McpConfigScope::User => "user", - McpConfigScope::Project => "project", - McpConfigScope::Local => "local", - } - } -} - -impl fmt::Display for McpConfigScope { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl FromStr for McpConfigScope { - type Err = String; - - fn from_str(s: &str) -> std::result::Result { - match s.to_ascii_lowercase().as_str() { - "user" => Ok(McpConfigScope::User), - "project" => Ok(McpConfigScope::Project), - "local" => Ok(McpConfigScope::Local), - other => Err(format!("Unknown MCP scope '{other}'")), - } - } -} - -/// A resolved MCP server entry annotated with its configuration scope. -#[derive(Debug, Clone)] -pub struct ScopedMcpServer { - pub scope: McpConfigScope, - pub config: McpServerConfig, -} - -/// Configuration for a predefined MCP resource reference. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct McpResourceConfig { - /// Named MCP server that owns this resource. - pub server: String, - /// URI or path identifying the resource within the server. - pub uri: String, - /// Optional short title displayed in UI. - #[serde(default)] - pub title: Option, - /// Optional detailed description shown in tooltips. - #[serde(default)] - pub description: Option, -} - -/// Resource entry annotated with its originating scope. -#[derive(Debug, Clone)] -pub struct ScopedMcpResource { - pub scope: McpConfigScope, - pub config: McpResourceConfig, -} - -impl Config { - fn default_schema_version() -> String { - CONFIG_SCHEMA_VERSION.to_string() - } - - /// Load configuration from disk, falling back to defaults when missing - pub fn load(path: Option<&Path>) -> Result { - let path = match path { - Some(path) => path.to_path_buf(), - None => default_config_path(), - }; - - if path.exists() { - let content = fs::read_to_string(&path)?; - let parsed: toml::Value = - toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))?; - let mut parsed = parsed; - migrate_legacy_provider_tables(&mut parsed); - let previous_version = parsed - .get("schema_version") - .and_then(|value| value.as_str()) - .unwrap_or("0.0.0") - .to_string(); - if let Some(agent_table) = parsed.get("agent").and_then(|value| value.as_table()) - && agent_table.contains_key("max_tool_calls") - { - log::warn!( - "Configuration option agent.max_tool_calls is deprecated and ignored. \ - The agent now uses agent.max_iterations." - ); - } - let mut config: Config = parsed - .try_into() - .map_err(|e: toml::de::Error| crate::Error::Config(e.to_string()))?; - config.ensure_defaults(); - config.mcp.apply_backward_compat(); - config.apply_schema_migrations(&previous_version); - config.expand_provider_env_vars()?; - config.refresh_mcp_servers(None)?; - config.validate()?; - Ok(config) - } else { - let mut config = Config::default(); - config.expand_provider_env_vars()?; - config.refresh_mcp_servers(None)?; - Ok(config) - } - } - - /// Generate MCP server configurations for a given reference preset. - pub fn preset_servers(tier: crate::mcp::presets::PresetTier) -> Vec { - crate::mcp::presets::connectors_for_tier(tier) - .into_iter() - .map(|connector| connector.to_config()) - .collect() - } - - /// Persist configuration to disk - pub fn save(&self, path: Option<&Path>) -> Result<()> { - let mut validator = self.clone(); - validator.refresh_mcp_servers(None)?; - validator.validate()?; - - let path = match path { - Some(path) => path.to_path_buf(), - None => default_config_path(), - }; - - if let Some(dir) = path.parent() { - fs::create_dir_all(dir)?; - } - - let mut snapshot = self.clone(); - snapshot.schema_version = Config::default_schema_version(); - let content = - toml::to_string_pretty(&snapshot).map_err(|e| crate::Error::Config(e.to_string()))?; - fs::write(path, content)?; - Ok(()) - } - - /// Get provider configuration by provider name - pub fn provider(&self, name: &str) -> Option<&ProviderConfig> { - let key = normalize_provider_key(name); - self.providers.get(&key) - } - - /// Update or insert a provider configuration - pub fn upsert_provider(&mut self, name: impl Into, config: ProviderConfig) { - let raw = name.into(); - let key = normalize_provider_key(&raw); - let mut config = config; - if config.provider_type.is_empty() { - config.provider_type = key.clone(); - } - self.providers.insert(key, config); - } - - /// Resolve default model in order of priority: explicit default, first cached model, provider fallback - pub fn resolve_default_model<'a>( - &'a self, - models: &'a [crate::types::ModelInfo], - ) -> Option<&'a str> { - if let Some(model) = self.general.default_model.as_deref() - && models.iter().any(|m| m.id == model || m.name == model) - { - return Some(model); - } - - if let Some(first) = models.first() { - return Some(&first.id); - } - - self.general.default_model.as_deref() - } - - fn ensure_defaults(&mut self) { - if self.general.default_provider.is_empty() || self.general.default_provider == "ollama" { - self.general.default_provider = "ollama_local".to_string(); - } - - let mut defaults = default_provider_configs(); - for (name, default_cfg) in defaults.drain() { - self.providers.entry(name).or_insert(default_cfg); - } - - if let Some(local) = self.providers.get_mut("ollama_local") { - normalize_local_provider_config(local); - } - if let Some(cloud) = self.providers.get_mut("ollama_cloud") { - normalize_cloud_provider_config(cloud); - } - - if self.schema_version.is_empty() { - self.schema_version = Self::default_schema_version(); - } - } - - fn expand_provider_env_vars(&mut self) -> Result<()> { - for (provider_name, provider) in self.providers.iter_mut() { - expand_provider_entry(provider_name, provider)?; - } - Ok(()) - } - - /// Refresh the resolved MCP server list by loading scope-specific definitions. - pub fn refresh_mcp_servers(&mut self, project_hint: Option<&Path>) -> Result<()> { - let mut scoped_servers = Vec::new(); - let mut scoped_resources = Vec::new(); - - let mut user_servers = self.mcp_servers.clone(); - expand_mcp_servers(&mut user_servers, "config.mcp_servers")?; - for server in user_servers { - scoped_servers.push(ScopedMcpServer { - scope: McpConfigScope::User, - config: server, - }); - } - - let mut user_resources = self.mcp_resources.clone(); - expand_mcp_resources(&mut user_resources, "config.mcp_resources")?; - for resource in user_resources { - scoped_resources.push(ScopedMcpResource { - scope: McpConfigScope::User, - config: resource, - }); - } - - for scope in [McpConfigScope::Project, McpConfigScope::Local] { - if let Some(path) = mcp_scope_path(scope, project_hint) { - let mut file = read_scope_config(&path)?; - let server_context = format!("mcp.{scope}.servers"); - expand_mcp_servers(&mut file.servers, &server_context)?; - for server in file.servers { - scoped_servers.push(ScopedMcpServer { - scope, - config: server, - }); - } - - let resource_context = format!("mcp.{scope}.resources"); - expand_mcp_resources(&mut file.resources, &resource_context)?; - for resource in file.resources { - scoped_resources.push(ScopedMcpResource { - scope, - config: resource, - }); - } - } - } - - let mut effective_servers = Vec::new(); - let mut seen_servers = HashSet::new(); - for scope in McpConfigScope::precedence_iter() { - for entry in scoped_servers.iter().filter(|entry| entry.scope == scope) { - if seen_servers.insert(entry.config.name.clone()) { - effective_servers.push(entry.config.clone()); - } - } - } - - let mut effective_resources = Vec::new(); - let mut seen_resources: HashSet<(String, String)> = HashSet::new(); - for scope in McpConfigScope::precedence_iter() { - for entry in scoped_resources.iter().filter(|entry| entry.scope == scope) { - let key = (entry.config.server.clone(), entry.config.uri.clone()); - if seen_resources.insert(key) { - effective_resources.push(entry.config.clone()); - } - } - } - - self.scoped_mcp_servers = scoped_servers; - self.effective_mcp_servers = effective_servers; - self.scoped_mcp_resources = scoped_resources; - self.effective_mcp_resources = effective_resources; - Ok(()) - } - - /// Return the merged MCP servers using scope precedence (local > project > user). - pub fn effective_mcp_servers(&self) -> &[McpServerConfig] { - &self.effective_mcp_servers - } - - /// Return MCP servers annotated with their originating scope. - pub fn scoped_mcp_servers(&self) -> &[ScopedMcpServer] { - &self.scoped_mcp_servers - } - - /// Return merged MCP resources using scope precedence (local > project > user). - pub fn effective_mcp_resources(&self) -> &[McpResourceConfig] { - &self.effective_mcp_resources - } - - /// Return scoped MCP resources with their origin scope metadata. - pub fn scoped_mcp_resources(&self) -> &[ScopedMcpResource] { - &self.scoped_mcp_resources - } - - /// Locate a configured resource by server and URI. - pub fn find_resource(&self, server: &str, uri: &str) -> Option<&McpResourceConfig> { - self.effective_mcp_resources - .iter() - .find(|resource| resource.server == server && resource.uri == uri) - } - - /// Add or replace an MCP server definition within the specified scope. - pub fn add_mcp_server( - &mut self, - scope: McpConfigScope, - server: McpServerConfig, - project_hint: Option<&Path>, - ) -> Result<()> { - match scope { - McpConfigScope::User => { - self.mcp_servers - .retain(|existing| existing.name != server.name); - self.mcp_servers.push(server); - } - other => { - let path = mcp_scope_path(other, project_hint).ok_or_else(|| { - Error::Config(format!( - "Unable to resolve project root for MCP scope '{}'", - other - )) - })?; - let mut file = read_scope_config(&path)?; - file.servers.retain(|existing| existing.name != server.name); - file.servers.push(server); - write_scope_config(&path, &file)?; - } - } - - self.refresh_mcp_servers(project_hint)?; - Ok(()) - } - - /// Remove an MCP server from the given scope, or infer the scope if omitted. - pub fn remove_mcp_server( - &mut self, - scope: Option, - name: &str, - project_hint: Option<&Path>, - ) -> Result> { - let target_scope = if let Some(scope) = scope { - scope - } else { - self.refresh_mcp_servers(project_hint)?; - match self - .scoped_mcp_servers - .iter() - .find(|entry| entry.config.name == name) - { - Some(entry) => entry.scope, - None => return Ok(None), - } - }; - - let removed = match target_scope { - McpConfigScope::User => { - let before = self.mcp_servers.len(); - self.mcp_servers.retain(|entry| entry.name != name); - before != self.mcp_servers.len() - } - other => { - let path = mcp_scope_path(other, project_hint).ok_or_else(|| { - Error::Config(format!( - "Unable to resolve project root for MCP scope '{}'", - other - )) - })?; - let mut file = read_scope_config(&path)?; - let before = file.servers.len(); - file.servers.retain(|entry| entry.name != name); - if before == file.servers.len() { - false - } else { - write_scope_config(&path, &file)?; - true - } - } - }; - - if removed { - self.refresh_mcp_servers(project_hint)?; - Ok(Some(target_scope)) - } else { - Ok(None) - } - } - - /// Validate configuration invariants and surface actionable error messages. - pub fn validate(&self) -> Result<()> { - self.validate_default_provider()?; - self.validate_mcp_settings()?; - self.validate_mcp_servers()?; - self.validate_providers()?; - self.chat.validate()?; - Ok(()) - } - - fn apply_schema_migrations(&mut self, previous_version: &str) { - if previous_version != CONFIG_SCHEMA_VERSION { - log::info!( - "Upgrading configuration schema from '{}' to '{}'", - previous_version, - CONFIG_SCHEMA_VERSION - ); - } - - self.migrate_provider_entries(); - if self.general.default_provider == "ollama" { - self.general.default_provider = "ollama_local".to_string(); - } - self.ensure_defaults(); - self.schema_version = CONFIG_SCHEMA_VERSION.to_string(); - } - - fn migrate_provider_entries(&mut self) { - let mut migrated = default_provider_configs(); - let legacy_entries = std::mem::take(&mut self.providers); - - for (original_key, mut legacy) in legacy_entries { - if original_key == "ollama" { - Self::merge_legacy_ollama_provider(legacy, &mut migrated); - continue; - } - - let normalized = normalize_provider_key(&original_key); - let entry = migrated - .entry(normalized.clone()) - .or_insert_with(|| ProviderConfig { - enabled: true, - provider_type: normalized.clone(), - base_url: None, - api_key: None, - api_key_env: None, - extra: HashMap::new(), - }); - - if legacy.provider_type.is_empty() { - legacy.provider_type = normalized.clone(); - } - - entry.merge_from(legacy); - if entry.provider_type.is_empty() { - entry.provider_type = normalized; - } - } - - self.providers = migrated; - - // If the legacy local provider was configured with the hosted base URL, promote the - // settings to the cloud provider for backward compatibility. - let enable_cloud_from_local = self - .providers - .get("ollama_local") - .and_then(|cfg| cfg.base_url.as_ref()) - .map(|base| is_cloud_base_url(Some(base))) - .unwrap_or(false); - - if enable_cloud_from_local { - let mut local_api_key = None; - let mut local_api_key_env = None; - if let Some(local) = self.providers.get_mut("ollama_local") { - local_api_key = local.api_key.take(); - local_api_key_env = local.api_key_env.take(); - local.enabled = false; - } - - if let Some(cloud) = self.providers.get_mut("ollama_cloud") { - if cloud.api_key.is_none() { - cloud.api_key = local_api_key; - } - if cloud.api_key_env.is_none() { - cloud.api_key_env = local_api_key_env; - } - - if cloud.base_url.is_none() { - cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); - } - let update_api_key_env = match cloud.api_key_env.as_deref() { - None => true, - Some(value) => { - value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) - || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) - } - }; - if update_api_key_env { - cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); - } - cloud.enabled = true; - } - } - } - - fn merge_legacy_ollama_provider( - mut legacy: ProviderConfig, - targets: &mut HashMap, - ) { - let mode = legacy - .extra - .remove(OLLAMA_MODE_KEY) - .and_then(|value| value.as_str().map(|s| s.trim().to_ascii_lowercase())); - - let api_key_present = legacy - .api_key - .as_ref() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); - let cloud_candidate = - matches!(mode.as_deref(), Some("cloud")) || is_cloud_base_url(legacy.base_url.as_ref()); - let should_enable_cloud = cloud_candidate || api_key_present; - - if (matches!(mode.as_deref(), Some("local")) || !should_enable_cloud) - && let Some(local) = targets.get_mut("ollama_local") - { - let mut copy = legacy.clone(); - copy.api_key = None; - copy.api_key_env = None; - copy.enabled = true; - local.merge_from(copy); - local.enabled = true; - if local.base_url.is_none() { - local.base_url = Some(OLLAMA_LOCAL_BASE_URL.to_string()); - } - } - - if (should_enable_cloud || matches!(mode.as_deref(), Some("cloud"))) - && let Some(cloud) = targets.get_mut("ollama_cloud") - { - legacy.enabled = true; - cloud.merge_from(legacy); - cloud.enabled = true; - if cloud.base_url.is_none() { - cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); - } - let update_api_key_env = match cloud.api_key_env.as_deref() { - None => true, - Some(value) => { - value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) - || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) - } - }; - if update_api_key_env { - cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); - } - } - } - - fn validate_default_provider(&self) -> Result<()> { - if self.general.default_provider.trim().is_empty() { - return Err(crate::Error::Config( - "general.default_provider must reference a configured provider".to_string(), - )); - } - - if self.provider(&self.general.default_provider).is_none() { - return Err(crate::Error::Config(format!( - "Default provider '{}' is not defined under [providers]", - self.general.default_provider - ))); - } - - Ok(()) - } - - fn validate_mcp_settings(&self) -> Result<()> { - let has_effective_servers = if self.effective_mcp_servers.is_empty() { - !self.mcp_servers.is_empty() - } else { - !self.effective_mcp_servers.is_empty() - }; - - match self.mcp.mode { - McpMode::RemoteOnly => { - if !has_effective_servers { - return Err(crate::Error::Config( - "[mcp].mode = 'remote_only' requires at least one [[mcp_servers]] entry" - .to_string(), - )); - } - } - McpMode::RemotePreferred => { - if !self.mcp.allow_fallback && !has_effective_servers { - return Err(crate::Error::Config( - "[mcp].allow_fallback = false requires at least one [[mcp_servers]] entry" - .to_string(), - )); - } - } - McpMode::Disabled => { - return Err(crate::Error::Config( - "[mcp].mode = 'disabled' is not supported by this build of Owlen".to_string(), - )); - } - _ => {} - } - - Ok(()) - } - - fn validate_mcp_servers(&self) -> Result<()> { - if self.scoped_mcp_servers.is_empty() { - for server in &self.mcp_servers { - validate_mcp_server_entry(server, McpConfigScope::User)?; - } - } else { - for entry in &self.scoped_mcp_servers { - validate_mcp_server_entry(&entry.config, entry.scope)?; - } - } - - Ok(()) - } - - fn validate_providers(&self) -> Result<()> { - for (name, provider) in &self.providers { - if !provider.enabled { - continue; - } - - match name.as_str() { - "ollama_local" => { - if is_blank(&provider.base_url) { - return Err(Error::Config( - "providers.ollama_local.base_url must be set when enabled".into(), - )); - } - } - "ollama_cloud" => { - if is_blank(&provider.base_url) { - return Err(Error::Config( - "providers.ollama_cloud.base_url must be set when enabled".into(), - )); - } - - if is_blank(&provider.api_key) && is_blank(&provider.api_key_env) { - return Err(Error::Config( - "providers.ollama_cloud requires `api_key` or `api_key_env` when enabled" - .into(), - )); - } - } - "openai" | "anthropic" => { - if is_blank(&provider.api_key) && is_blank(&provider.api_key_env) { - return Err(Error::Config(format!( - "providers.{name} requires `api_key` or `api_key_env` when enabled" - ))); - } - } - _ => {} - } - } - - Ok(()) - } -} - -fn default_provider_configs() -> HashMap { - let mut providers = HashMap::new(); - for name in ["ollama_local", "ollama_cloud", "openai", "anthropic"] { - if let Some(config) = default_provider_config_for(name) { - providers.insert(name.to_string(), config); - } - } - providers -} - -fn default_ollama_local_config() -> ProviderConfig { - let mut extra = HashMap::new(); - extra.insert( - "list_ttl_secs".to_string(), - serde_json::Value::Number(serde_json::Number::from(DEFAULT_PROVIDER_LIST_TTL_SECS)), - ); - extra.insert( - "default_context_window".to_string(), - serde_json::Value::Number(serde_json::Number::from(u64::from( - DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS, - ))), - ); - - ProviderConfig { - enabled: true, - provider_type: canonical_provider_type("ollama_local"), - base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()), - api_key: None, - api_key_env: None, - extra, - } -} - -fn default_ollama_cloud_config() -> ProviderConfig { - let mut extra = HashMap::new(); - extra.insert( - OLLAMA_CLOUD_ENDPOINT_KEY.to_string(), - serde_json::Value::String(OLLAMA_CLOUD_BASE_URL.to_string()), - ); - extra.insert( - "hourly_quota_tokens".to_string(), - serde_json::Value::Number(serde_json::Number::from(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA)), - ); - extra.insert( - "weekly_quota_tokens".to_string(), - serde_json::Value::Number(serde_json::Number::from(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA)), - ); - extra.insert( - "list_ttl_secs".to_string(), - serde_json::Value::Number(serde_json::Number::from(DEFAULT_PROVIDER_LIST_TTL_SECS)), - ); - extra.insert( - "default_context_window".to_string(), - serde_json::Value::Number(serde_json::Number::from(u64::from( - DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS, - ))), - ); - - ProviderConfig { - enabled: false, - provider_type: canonical_provider_type("ollama_cloud"), - base_url: Some(OLLAMA_CLOUD_BASE_URL.to_string()), - api_key: None, - api_key_env: Some(OLLAMA_API_KEY_ENV.to_string()), - extra, - } -} - -fn default_openai_config() -> ProviderConfig { - ProviderConfig { - enabled: false, - provider_type: canonical_provider_type("openai"), - base_url: Some(OPENAI_DEFAULT_BASE_URL.to_string()), - api_key: None, - api_key_env: Some(OPENAI_API_KEY_ENV.to_string()), - extra: HashMap::new(), - } -} - -fn default_anthropic_config() -> ProviderConfig { - ProviderConfig { - enabled: false, - provider_type: canonical_provider_type("anthropic"), - base_url: Some(ANTHROPIC_DEFAULT_BASE_URL.to_string()), - api_key: None, - api_key_env: Some(ANTHROPIC_API_KEY_ENV.to_string()), - extra: HashMap::new(), - } -} - -fn default_provider_config_for(name: &str) -> Option { - match name { - "ollama_local" => Some(default_ollama_local_config()), - "ollama_cloud" => Some(default_ollama_cloud_config()), - "openai" => Some(default_openai_config()), - "anthropic" => Some(default_anthropic_config()), - _ => None, - } -} - -fn ensure_numeric_extra( - extra: &mut HashMap, - key: &str, - default_value: u64, -) { - let needs_update = match extra.get(key) { - Some(existing) => existing.as_u64().is_none(), - None => true, - }; - - if needs_update { - extra.insert( - key.to_string(), - serde_json::Value::Number(serde_json::Number::from(default_value)), - ); - } -} - -fn ensure_string_extra( - extra: &mut HashMap, - key: &str, - default_value: &str, -) { - let needs_update = match extra.get(key) { - Some(existing) => existing - .as_str() - .map(|value| value.trim().is_empty()) - .unwrap_or(true), - None => true, - }; - - if needs_update { - extra.insert( - key.to_string(), - serde_json::Value::String(default_value.to_string()), - ); - } -} - -fn normalize_local_provider_config(provider: &mut ProviderConfig) { - if provider.provider_type.trim().is_empty() || provider.provider_type != "ollama" { - provider.provider_type = "ollama".to_string(); - } - - ensure_numeric_extra( - &mut provider.extra, - "list_ttl_secs", - DEFAULT_PROVIDER_LIST_TTL_SECS, - ); - ensure_numeric_extra( - &mut provider.extra, - "default_context_window", - u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), - ); -} - -fn normalize_cloud_provider_config(provider: &mut ProviderConfig) { - if provider.provider_type.trim().is_empty() - || !provider.provider_type.eq_ignore_ascii_case("ollama_cloud") - { - provider.provider_type = "ollama_cloud".to_string(); - } - - match provider - .base_url - .as_ref() - .map(|value| value.trim_end_matches('/')) - { - None => { - provider.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); - } - Some(current) if current.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_BASE_URL) => { - provider.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); - } - _ => {} - } - - if provider - .api_key_env - .as_ref() - .map(|value| value.trim().is_empty()) - .unwrap_or(true) - { - provider.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); - } - - ensure_string_extra( - &mut provider.extra, - OLLAMA_CLOUD_ENDPOINT_KEY, - OLLAMA_CLOUD_BASE_URL, - ); - ensure_numeric_extra( - &mut provider.extra, - "hourly_quota_tokens", - DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA, - ); - ensure_numeric_extra( - &mut provider.extra, - "weekly_quota_tokens", - DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, - ); - ensure_numeric_extra( - &mut provider.extra, - "list_ttl_secs", - DEFAULT_PROVIDER_LIST_TTL_SECS, - ); - ensure_numeric_extra( - &mut provider.extra, - "default_context_window", - u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), - ); -} - -fn normalize_provider_key(name: &str) -> String { - let normalized = name.trim().to_ascii_lowercase(); - match normalized.as_str() { - "ollama" | "ollama-local" => "ollama_local".to_string(), - "ollama_cloud" | "ollama-cloud" => "ollama_cloud".to_string(), - other => other.replace('-', "_"), - } -} - -fn canonical_provider_type(key: &str) -> String { - match key { - "ollama_local" => "ollama".to_string(), - other => other.to_string(), - } -} - -fn is_blank(value: &Option) -> bool { - value.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) -} - -fn migrate_legacy_provider_tables(document: &mut toml::Value) { - let Some(table) = document.as_table_mut() else { - return; - }; - - let mut legacy = Vec::new(); - for key in ["ollama", "ollama_cloud", "ollama-cloud"] { - if let Some(entry) = table.remove(key) { - legacy.push((key.to_string(), entry)); - } - } - - if legacy.is_empty() { - return; - } - - let providers_entry = table - .entry("providers".to_string()) - .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); - - if let Some(providers_table) = providers_entry.as_table_mut() { - for (key, value) in legacy { - providers_table.insert(key, value); - } - } -} - -fn is_cloud_base_url(base_url: Option<&String>) -> bool { - base_url - .map(|url| { - let trimmed = url.trim_end_matches('/'); - trimmed == OLLAMA_CLOUD_BASE_URL - || trimmed == LEGACY_OLLAMA_CLOUD_BASE_URL - || trimmed.starts_with("https://ollama.com/") - || trimmed.starts_with("https://api.ollama.com/") - }) - .unwrap_or(false) -} - -fn validate_mcp_server_entry(server: &McpServerConfig, scope: McpConfigScope) -> Result<()> { - if server.name.trim().is_empty() { - return Err(Error::Config(format!( - "Each MCP server entry must include a non-empty name (scope: {scope})" - ))); - } - - if server.command.trim().is_empty() { - return Err(Error::Config(format!( - "MCP server '{}' must define a command or endpoint (scope: {scope})", - server.name - ))); - } - - let transport = server.transport.to_lowercase(); - if !matches!(transport.as_str(), "stdio" | "http" | "websocket") { - return Err(Error::Config(format!( - "Unknown MCP transport '{}' for server '{}' (scope: {scope})", - server.transport, server.name - ))); - } - - if let Some(oauth) = &server.oauth { - if oauth.client_id.trim().is_empty() { - return Err(Error::Config(format!( - "MCP server '{}' defines OAuth without a client_id", - server.name - ))); - } - if oauth.authorize_url.trim().is_empty() { - return Err(Error::Config(format!( - "MCP server '{}' defines OAuth without an authorize_url", - server.name - ))); - } - if oauth.token_url.trim().is_empty() { - return Err(Error::Config(format!( - "MCP server '{}' defines OAuth without a token_url", - server.name - ))); - } - if oauth.device_authorization_url.is_none() && oauth.redirect_url.is_none() { - return Err(Error::Config(format!( - "MCP server '{}' must define either device_authorization_url or redirect_url for OAuth flows", - server.name - ))); - } - } - - Ok(()) -} - -fn expand_provider_entry(provider_name: &str, provider: &mut ProviderConfig) -> Result<()> { - if let Some(ref mut base_url) = provider.base_url { - let expanded = expand_env_string( - base_url.as_str(), - &format!("providers.{provider_name}.base_url"), - )?; - *base_url = expanded; - } - - if let Some(ref mut api_key) = provider.api_key { - let expanded = expand_env_string( - api_key.as_str(), - &format!("providers.{provider_name}.api_key"), - )?; - *api_key = expanded; - } - - for (extra_key, extra_value) in provider.extra.iter_mut() { - if let serde_json::Value::String(current) = extra_value { - let expanded = expand_env_string( - current.as_str(), - &format!("providers.{provider_name}.{}", extra_key), - )?; - *current = expanded; - } - } - - Ok(()) -} - -fn expand_mcp_servers(servers: &mut [McpServerConfig], field_path: &str) -> Result<()> { - for (idx, server) in servers.iter_mut().enumerate() { - expand_mcp_server_entry(server, field_path, idx)?; - } - Ok(()) -} - -fn expand_mcp_server_entry( - server: &mut McpServerConfig, - field_path: &str, - index: usize, -) -> Result<()> { - server.command = expand_env_string( - server.command.as_str(), - &format!("{field_path}[{index}].command"), - )?; - - for (arg_idx, arg) in server.args.iter_mut().enumerate() { - *arg = expand_env_string( - arg.as_str(), - &format!("{field_path}[{index}].args[{arg_idx}]"), - )?; - } - - for (env_key, env_value) in server.env.iter_mut() { - *env_value = expand_env_string( - env_value.as_str(), - &format!("{field_path}[{index}].env.{env_key}"), - )?; - } - - if let Some(oauth) = server.oauth.as_mut() { - oauth.client_id = expand_env_string( - oauth.client_id.as_str(), - &format!("{field_path}[{index}].oauth.client_id"), - )?; - oauth.authorize_url = expand_env_string( - oauth.authorize_url.as_str(), - &format!("{field_path}[{index}].oauth.authorize_url"), - )?; - oauth.token_url = expand_env_string( - oauth.token_url.as_str(), - &format!("{field_path}[{index}].oauth.token_url"), - )?; - - if let Some(secret) = oauth.client_secret.as_mut() { - *secret = expand_env_string( - secret.as_str(), - &format!("{field_path}[{index}].oauth.client_secret"), - )?; - } - - if let Some(device_url) = oauth.device_authorization_url.as_mut() { - *device_url = expand_env_string( - device_url.as_str(), - &format!("{field_path}[{index}].oauth.device_authorization_url"), - )?; - } - - if let Some(redirect) = oauth.redirect_url.as_mut() { - *redirect = expand_env_string( - redirect.as_str(), - &format!("{field_path}[{index}].oauth.redirect_url"), - )?; - } - - if let Some(token_env) = oauth.token_env.as_mut() { - *token_env = expand_env_string( - token_env.as_str(), - &format!("{field_path}[{index}].oauth.token_env"), - )?; - } - - if let Some(header) = oauth.header.as_mut() { - *header = expand_env_string( - header.as_str(), - &format!("{field_path}[{index}].oauth.header"), - )?; - } - - if let Some(prefix) = oauth.header_prefix.as_mut() { - *prefix = expand_env_string( - prefix.as_str(), - &format!("{field_path}[{index}].oauth.header_prefix"), - )?; - } - - for (scope_idx, scope) in oauth.scopes.iter_mut().enumerate() { - *scope = expand_env_string( - scope.as_str(), - &format!("{field_path}[{index}].oauth.scopes[{scope_idx}]"), - )?; - } - } - - Ok(()) -} - -fn expand_mcp_resources(resources: &mut [McpResourceConfig], field_path: &str) -> Result<()> { - for (idx, resource) in resources.iter_mut().enumerate() { - expand_mcp_resource_entry(resource, field_path, idx)?; - } - Ok(()) -} - -fn expand_mcp_resource_entry( - resource: &mut McpResourceConfig, - field_path: &str, - index: usize, -) -> Result<()> { - resource.server = expand_env_string( - resource.server.as_str(), - &format!("{field_path}[{index}].server"), - )?; - resource.uri = expand_env_string(resource.uri.as_str(), &format!("{field_path}[{index}].uri"))?; - - if let Some(title) = resource.title.as_mut() { - *title = expand_env_string(title.as_str(), &format!("{field_path}[{index}].title"))?; - } - - if let Some(description) = resource.description.as_mut() { - *description = expand_env_string( - description.as_str(), - &format!("{field_path}[{index}].description"), - )?; - } - - Ok(()) -} - -fn expand_env_string(input: &str, field_path: &str) -> Result { - if !input.contains('$') { - return Ok(input.to_string()); - } - - match shellexpand::env(input) { - Ok(expanded) => Ok(expanded.into_owned()), - Err(err) => match err.cause { - std::env::VarError::NotPresent => Err(crate::Error::Config(format!( - "Environment variable {} referenced in {field_path} is not set", - err.var_name - ))), - std::env::VarError::NotUnicode(_) => Err(crate::Error::Config(format!( - "Environment variable {} referenced in {field_path} contains invalid Unicode", - err.var_name - ))), - }, - } -} - -/// Default configuration path with user home expansion -pub fn default_config_path() -> PathBuf { - if let Some(config_dir) = dirs::config_dir() { - return config_dir.join("owlen").join("config.toml"); - } - - PathBuf::from(shellexpand::tilde(DEFAULT_CONFIG_PATH).as_ref()) -} - -#[derive(Serialize, Deserialize, Default, Clone)] -struct McpConfigFile { - #[serde(default)] - servers: Vec, - #[serde(default)] - resources: Vec, -} - -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -enum McpConfigEnvelope { - Array(Vec), - Object(McpConfigFile), -} - -fn read_scope_config(path: &Path) -> Result { - if !path.exists() { - return Ok(McpConfigFile::default()); - } - - let contents = fs::read_to_string(path).map_err(Error::Io)?; - if contents.trim().is_empty() { - return Ok(McpConfigFile::default()); - } - - let doc: McpConfigEnvelope = serde_json::from_str(&contents).map_err(|err| { - Error::Config(format!( - "Failed to parse MCP configuration at {}: {err}", - path.display() - )) - })?; - - Ok(match doc { - McpConfigEnvelope::Array(servers) => McpConfigFile { - servers, - resources: Vec::new(), - }, - McpConfigEnvelope::Object(doc) => doc, - }) -} - -fn write_scope_config(path: &Path, file: &McpConfigFile) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(Error::Io)?; - } - - let serialized = serde_json::to_string_pretty(file).map_err(|err| { - Error::Config(format!( - "Failed to serialize MCP configuration for {}: {err}", - path.display() - )) - })?; - - fs::write(path, serialized).map_err(Error::Io) -} - -/// Resolve the configuration file path for a given scope. -pub fn mcp_scope_path(scope: McpConfigScope, project_hint: Option<&Path>) -> Option { - match scope { - McpConfigScope::User => dirs::config_dir() - .or_else(|| Some(PathBuf::from(shellexpand::tilde("~/.config").as_ref()))) - .map(|dir| dir.join("owlen").join("mcp.json")), - McpConfigScope::Project | McpConfigScope::Local => { - let root = project_hint - .map(PathBuf::from) - .or_else(|| discover_project_root(None))?; - - if matches!(scope, McpConfigScope::Project) { - Some(root.join(".mcp.json")) - } else { - Some(root.join(".owlen").join("mcp.local.json")) - } - } - } -} - -fn discover_project_root(start: Option<&Path>) -> Option { - let mut current = start - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok())?; - - loop { - if current.join(".mcp.json").exists() - || current.join(".owlen").exists() - || current.join(".git").exists() - || current.join("Cargo.toml").exists() - { - return Some(current); - } - - if !current.pop() { - break; - } - } - - start - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()) -} - -/// General behaviour settings shared across clients -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeneralSettings { - /// Default provider name for routing - pub default_provider: String, - /// Optional default model id - #[serde(default)] - pub default_model: Option, - /// Whether streaming responses are preferred - #[serde(default = "GeneralSettings::default_streaming")] - pub enable_streaming: bool, - /// Optional path to a project context file automatically injected as system prompt - #[serde(default)] - pub project_context_file: Option, - /// TTL for cached model listings in seconds - #[serde(default = "GeneralSettings::default_model_cache_ttl")] - pub model_cache_ttl_secs: u64, - /// TTL for cached health checks in seconds - #[serde(default = "GeneralSettings::default_health_check_ttl")] - pub health_check_ttl_secs: u64, -} - -impl GeneralSettings { - fn default_streaming() -> bool { - true - } - - fn default_model_cache_ttl() -> u64 { - 60 - } - - fn default_health_check_ttl() -> u64 { - 30 - } - - /// Duration representation of model cache TTL - pub fn model_cache_ttl(&self) -> Duration { - Duration::from_secs(self.model_cache_ttl_secs.max(5)) - } - - /// Duration representation of health check cache TTL - pub fn health_check_ttl(&self) -> Duration { - Duration::from_secs(self.health_check_ttl_secs.max(5)) - } -} - -impl Default for GeneralSettings { - fn default() -> Self { - Self { - default_provider: "ollama_local".to_string(), - default_model: Some("llama3.2:latest".to_string()), - enable_streaming: Self::default_streaming(), - project_context_file: Some("OWLEN.md".to_string()), - model_cache_ttl_secs: Self::default_model_cache_ttl(), - health_check_ttl_secs: Self::default_health_check_ttl(), - } - } -} - -/// Strategy used for compressing historical conversation turns. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum CompressionStrategy { - /// Use the active (or override) model to generate a summary. - #[default] - Provider, - /// Use Owlen's built-in heuristic summariser without model calls. - Local, -} - -/// Chat-specific configuration (history compression, retention, etc.) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatSettings { - #[serde(default = "ChatSettings::default_auto_compress")] - pub auto_compress: bool, - #[serde(default = "ChatSettings::default_trigger_tokens")] - pub trigger_tokens: u32, - #[serde(default = "ChatSettings::default_retain_recent")] - pub retain_recent_messages: usize, - #[serde(default)] - pub model_override: Option, - #[serde(default)] - pub strategy: CompressionStrategy, -} - -impl ChatSettings { - const fn default_auto_compress() -> bool { - true - } - - const fn default_trigger_tokens() -> u32 { - 6_000 - } - - const fn default_retain_recent() -> usize { - 8 - } - - pub fn validate(&self) -> Result<()> { - if self.trigger_tokens < 64 { - return Err(crate::Error::Config( - "chat.trigger_tokens must be at least 64".to_string(), - )); - } - if self.retain_recent_messages < 2 { - return Err(crate::Error::Config( - "chat.retain_recent_messages must be at least 2".to_string(), - )); - } - Ok(()) - } -} - -impl Default for ChatSettings { - fn default() -> Self { - Self { - auto_compress: Self::default_auto_compress(), - trigger_tokens: Self::default_trigger_tokens(), - retain_recent_messages: Self::default_retain_recent(), - model_override: None, - strategy: CompressionStrategy::default(), - } - } -} - -/// Operating modes for the MCP subsystem. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum McpMode { - /// Prefer remote MCP servers when configured, but allow local fallback. - #[serde(alias = "enabled", alias = "auto")] - RemotePreferred, - /// Require a configured remote MCP server; fail if none are available. - RemoteOnly, - /// Always use the in-process MCP server for tooling. - #[serde(alias = "local")] - LocalOnly, - /// Compatibility shim for pre-v1.0 behaviour; treated as `local_only`. - Legacy, - /// Disable MCP entirely (not recommended). - Disabled, -} - -impl Default for McpMode { - fn default() -> Self { - Self::RemotePreferred - } -} - -impl McpMode { - /// Whether this mode requires a remote MCP server. - pub const fn requires_remote(self) -> bool { - matches!(self, Self::RemoteOnly) - } - - /// Whether this mode prefers to use a remote MCP server when available. - pub const fn prefers_remote(self) -> bool { - matches!(self, Self::RemotePreferred | Self::RemoteOnly) - } - - /// Whether this mode should operate purely locally. - pub const fn is_local(self) -> bool { - matches!(self, Self::LocalOnly | Self::Legacy) - } -} - -/// MCP (Multi-Client-Provider) settings -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpSettings { - /// Operating mode for MCP integration. - #[serde(default)] - pub mode: McpMode, - /// Allow falling back to the local MCP client when remote startup fails. - #[serde(default = "McpSettings::default_allow_fallback")] - pub allow_fallback: bool, - /// Emit a warning when the deprecated `legacy` mode is used. - #[serde(default = "McpSettings::default_warn_on_legacy")] - pub warn_on_legacy: bool, -} - -impl McpSettings { - const fn default_allow_fallback() -> bool { - true - } - - const fn default_warn_on_legacy() -> bool { - true - } - - fn apply_backward_compat(&mut self) { - if self.mode == McpMode::Legacy && self.warn_on_legacy { - log::warn!( - "MCP legacy mode detected. This mode will be removed in a future release; \ - switch to 'local_only' or 'remote_preferred' after verifying your setup." - ); - } - } -} - -impl Default for McpSettings { - fn default() -> Self { - let mut settings = Self { - mode: McpMode::default(), - allow_fallback: Self::default_allow_fallback(), - warn_on_legacy: Self::default_warn_on_legacy(), - }; - settings.apply_backward_compat(); - settings - } -} - -/// Privacy controls governing network access and storage -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PrivacySettings { - #[serde(default = "PrivacySettings::default_remote_search")] - pub enable_remote_search: bool, - #[serde(default)] - pub cache_web_results: bool, - #[serde(default)] - pub retain_history_days: u32, - #[serde(default = "PrivacySettings::default_require_consent")] - pub require_consent_per_session: bool, - #[serde(default = "PrivacySettings::default_encrypt_local_data")] - pub encrypt_local_data: bool, -} - -impl PrivacySettings { - const fn default_remote_search() -> bool { - false - } - - const fn default_require_consent() -> bool { - true - } - - const fn default_encrypt_local_data() -> bool { - true - } -} - -impl Default for PrivacySettings { - fn default() -> Self { - Self { - enable_remote_search: Self::default_remote_search(), - cache_web_results: false, - retain_history_days: 0, - require_consent_per_session: Self::default_require_consent(), - encrypt_local_data: Self::default_encrypt_local_data(), - } - } -} - -/// Security settings that constrain tool execution -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "kebab-case")] -pub enum ApprovalMode { - #[default] - Auto, - ReadOnly, - PlanFirst, -} - -impl ApprovalMode { - pub fn as_str(self) -> &'static str { - match self { - ApprovalMode::Auto => "auto", - ApprovalMode::ReadOnly => "read-only", - ApprovalMode::PlanFirst => "plan-first", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SecuritySettings { - #[serde(default = "SecuritySettings::default_enable_sandboxing")] - pub enable_sandboxing: bool, - #[serde(default = "SecuritySettings::default_timeout")] - pub sandbox_timeout_seconds: u64, - #[serde(default = "SecuritySettings::default_max_memory")] - pub max_memory_mb: u64, - #[serde(default = "SecuritySettings::default_allowed_tools")] - pub allowed_tools: Vec, - #[serde(default)] - pub approval_mode: ApprovalMode, -} - -impl SecuritySettings { - const fn default_enable_sandboxing() -> bool { - true - } - - const fn default_timeout() -> u64 { - 30 - } - - const fn default_max_memory() -> u64 { - 512 - } - - fn default_allowed_tools() -> Vec { - vec![ - WEB_SEARCH_TOOL_NAME.to_string(), - "web_scrape".to_string(), - "code_exec".to_string(), - "file_write".to_string(), - "file_delete".to_string(), - ] - } - - pub fn approval_mode(&self) -> ApprovalMode { - self.approval_mode - } -} - -impl Default for SecuritySettings { - fn default() -> Self { - Self { - enable_sandboxing: Self::default_enable_sandboxing(), - sandbox_timeout_seconds: Self::default_timeout(), - max_memory_mb: Self::default_max_memory(), - allowed_tools: Self::default_allowed_tools(), - approval_mode: ApprovalMode::default(), - } - } -} - -/// Per-tool configuration toggles -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ToolSettings { - #[serde(default)] - pub web_search: WebSearchToolConfig, - #[serde(default)] - pub code_exec: CodeExecToolConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WebSearchToolConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub api_key: String, - #[serde(default = "WebSearchToolConfig::default_max_results")] - pub max_results: u32, -} - -impl WebSearchToolConfig { - const fn default_max_results() -> u32 { - 5 - } -} - -impl Default for WebSearchToolConfig { - fn default() -> Self { - Self { - enabled: false, - api_key: String::new(), - max_results: Self::default_max_results(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CodeExecToolConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "CodeExecToolConfig::default_allowed_languages")] - pub allowed_languages: Vec, - #[serde(default = "CodeExecToolConfig::default_timeout")] - pub timeout_seconds: u64, -} - -impl CodeExecToolConfig { - fn default_allowed_languages() -> Vec { - vec!["python".to_string(), "javascript".to_string()] - } - - const fn default_timeout() -> u64 { - 30 - } -} - -impl Default for CodeExecToolConfig { - fn default() -> Self { - Self { - enabled: false, - allowed_languages: Self::default_allowed_languages(), - timeout_seconds: Self::default_timeout(), - } - } -} - -/// UI preferences that consumers can respect as needed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiSettings { - #[serde(default = "UiSettings::default_theme")] - pub theme: String, - #[serde(default = "UiSettings::default_word_wrap")] - pub word_wrap: bool, - #[serde(default = "UiSettings::default_max_history_lines")] - pub max_history_lines: usize, - #[serde( - rename = "role_label", - alias = "show_role_labels", - default = "UiSettings::default_role_label_mode", - deserialize_with = "UiSettings::deserialize_role_label_mode" - )] - pub role_label_mode: RoleLabelDisplay, - #[serde(default = "UiSettings::default_wrap_column")] - pub wrap_column: u16, - #[serde(default = "UiSettings::default_show_onboarding")] - pub show_onboarding: bool, - #[serde(default = "UiSettings::default_input_max_rows")] - pub input_max_rows: u16, - #[serde(default = "UiSettings::default_scrollback_lines")] - pub scrollback_lines: usize, - #[serde(default = "UiSettings::default_show_cursor_outside_insert")] - pub show_cursor_outside_insert: bool, - #[serde(default = "UiSettings::default_syntax_highlighting")] - pub syntax_highlighting: bool, - #[serde(default = "UiSettings::default_render_markdown")] - pub render_markdown: bool, - #[serde(default = "UiSettings::default_show_timestamps")] - pub show_timestamps: bool, - #[serde(default = "UiSettings::default_icon_mode")] - pub icon_mode: IconMode, - #[serde(default = "UiSettings::default_keymap_profile")] - pub keymap_profile: Option, - #[serde(default = "UiSettings::default_keymap_leader")] - pub keymap_leader: String, - #[serde(default)] - pub keymap_path: Option, - #[serde(default)] - pub accessibility: AccessibilitySettings, - #[serde(default)] - pub layers: LayerSettings, - #[serde(default)] - pub animations: AnimationSettings, - #[serde(default)] - pub guidance: GuidanceSettings, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccessibilitySettings { - #[serde(default = "AccessibilitySettings::default_high_contrast")] - pub high_contrast: bool, - #[serde(default = "AccessibilitySettings::default_reduced_chrome")] - pub reduced_chrome: bool, -} - -impl AccessibilitySettings { - const fn default_high_contrast() -> bool { - false - } - - const fn default_reduced_chrome() -> bool { - false - } -} - -impl Default for AccessibilitySettings { - fn default() -> Self { - Self { - high_contrast: Self::default_high_contrast(), - reduced_chrome: Self::default_reduced_chrome(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GuidanceSettings { - #[serde(default = "GuidanceSettings::default_coach_marks_complete")] - pub coach_marks_complete: bool, -} - -impl GuidanceSettings { - const fn default_coach_marks_complete() -> bool { - false - } -} - -impl Default for GuidanceSettings { - fn default() -> Self { - Self { - coach_marks_complete: Self::default_coach_marks_complete(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LayerSettings { - #[serde(default = "LayerSettings::default_shadow_elevation")] - pub shadow_elevation: u8, - #[serde(default = "LayerSettings::default_glass_tint")] - pub glass_tint: f32, - #[serde(default = "LayerSettings::default_neon_intensity")] - pub neon_intensity: u8, - #[serde(default = "LayerSettings::default_focus_ring")] - pub focus_ring: bool, -} - -impl LayerSettings { - const fn default_shadow_elevation() -> u8 { - 2 - } - - const fn default_neon_intensity() -> u8 { - 60 - } - - const fn default_focus_ring() -> bool { - true - } - - const fn default_glass_tint() -> f32 { - 0.82 - } - - pub fn shadow_depth(&self) -> u8 { - self.shadow_elevation.min(3) - } - - pub fn neon_factor(&self) -> f64 { - (self.neon_intensity as f64).clamp(0.0, 100.0) / 100.0 - } - - pub fn glass_tint_factor(&self) -> f64 { - self.glass_tint.clamp(0.0, 1.0) as f64 - } -} - -impl Default for LayerSettings { - fn default() -> Self { - Self { - shadow_elevation: Self::default_shadow_elevation(), - glass_tint: Self::default_glass_tint(), - neon_intensity: Self::default_neon_intensity(), - focus_ring: Self::default_focus_ring(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnimationSettings { - #[serde(default = "AnimationSettings::default_micro")] - pub micro: bool, - #[serde(default = "AnimationSettings::default_gauge_smoothing")] - pub gauge_smoothing: f32, - #[serde(default = "AnimationSettings::default_pane_decay")] - pub pane_decay: f32, -} - -impl AnimationSettings { - const fn default_micro() -> bool { - true - } - - const fn default_gauge_smoothing() -> f32 { - 0.24 - } - - const fn default_pane_decay() -> f32 { - 0.68 - } - - pub fn gauge_smoothing_factor(&self) -> f64 { - self.gauge_smoothing.clamp(0.05, 1.0) as f64 - } - - pub fn pane_decay_factor(&self) -> f64 { - self.pane_decay.clamp(0.2, 0.95) as f64 - } -} - -impl Default for AnimationSettings { - fn default() -> Self { - Self { - micro: Self::default_micro(), - gauge_smoothing: Self::default_gauge_smoothing(), - pane_decay: Self::default_pane_decay(), - } - } -} - -/// Preference for which symbol set to render in the terminal UI. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum IconMode { - /// Automatically detect support for Nerd Font glyphs. - #[default] - Auto, - /// Use only ASCII-safe symbols. - Ascii, - /// Force Nerd Font glyphs regardless of detection heuristics. - Nerd, -} - -impl UiSettings { - fn default_theme() -> String { - "default_dark".to_string() - } - - fn default_word_wrap() -> bool { - true - } - - fn default_max_history_lines() -> usize { - 2000 - } - - const fn default_role_label_mode() -> RoleLabelDisplay { - RoleLabelDisplay::Above - } - - fn default_wrap_column() -> u16 { - 100 - } - - const fn default_show_onboarding() -> bool { - true - } - - const fn default_input_max_rows() -> u16 { - 5 - } - - const fn default_scrollback_lines() -> usize { - 2000 - } - - const fn default_show_cursor_outside_insert() -> bool { - false - } - - const fn default_syntax_highlighting() -> bool { - true - } - - const fn default_render_markdown() -> bool { - true - } - - const fn default_show_timestamps() -> bool { - true - } - - const fn default_icon_mode() -> IconMode { - IconMode::Auto - } - - fn default_keymap_profile() -> Option { - None - } - - fn default_keymap_leader() -> String { - "Space".to_string() - } - - fn deserialize_role_label_mode<'de, D>( - deserializer: D, - ) -> std::result::Result - where - D: Deserializer<'de>, - { - struct RoleLabelModeVisitor; - - impl<'de> Visitor<'de> for RoleLabelModeVisitor { - type Value = RoleLabelDisplay; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("`inline`, `above`, `none`, or a legacy boolean") - } - - fn visit_str(self, v: &str) -> std::result::Result - where - E: de::Error, - { - match v.trim().to_ascii_lowercase().as_str() { - "inline" => Ok(RoleLabelDisplay::Inline), - "above" => Ok(RoleLabelDisplay::Above), - "none" => Ok(RoleLabelDisplay::None), - other => Err(de::Error::unknown_variant( - other, - &["inline", "above", "none"], - )), - } - } - - fn visit_string(self, v: String) -> std::result::Result - where - E: de::Error, - { - self.visit_str(&v) - } - - fn visit_bool(self, v: bool) -> std::result::Result - where - E: de::Error, - { - if v { - Ok(RoleLabelDisplay::Above) - } else { - Ok(RoleLabelDisplay::None) - } - } - } - - deserializer.deserialize_any(RoleLabelModeVisitor) - } -} - -impl Default for UiSettings { - fn default() -> Self { - Self { - theme: Self::default_theme(), - word_wrap: Self::default_word_wrap(), - max_history_lines: Self::default_max_history_lines(), - role_label_mode: Self::default_role_label_mode(), - wrap_column: Self::default_wrap_column(), - show_onboarding: Self::default_show_onboarding(), - input_max_rows: Self::default_input_max_rows(), - scrollback_lines: Self::default_scrollback_lines(), - show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), - syntax_highlighting: Self::default_syntax_highlighting(), - render_markdown: Self::default_render_markdown(), - show_timestamps: Self::default_show_timestamps(), - icon_mode: Self::default_icon_mode(), - keymap_profile: Self::default_keymap_profile(), - keymap_leader: Self::default_keymap_leader(), - keymap_path: None, - accessibility: AccessibilitySettings::default(), - layers: LayerSettings::default(), - animations: AnimationSettings::default(), - guidance: GuidanceSettings::default(), - } - } -} - -/// Storage related preferences -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StorageSettings { - #[serde(default = "StorageSettings::default_conversation_dir")] - pub conversation_dir: Option, - #[serde(default = "StorageSettings::default_auto_save")] - pub auto_save_sessions: bool, - #[serde(default = "StorageSettings::default_max_sessions")] - pub max_saved_sessions: usize, - #[serde(default = "StorageSettings::default_session_timeout")] - pub session_timeout_minutes: u64, - #[serde(default = "StorageSettings::default_generate_descriptions")] - pub generate_descriptions: bool, -} - -impl StorageSettings { - fn default_conversation_dir() -> Option { - None - } - - fn default_auto_save() -> bool { - true - } - - fn default_max_sessions() -> usize { - 25 - } - - fn default_session_timeout() -> u64 { - 120 - } - - fn default_generate_descriptions() -> bool { - true - } - - /// Resolve storage directory path - /// Uses platform-specific data directory if not explicitly configured: - /// - Linux: ~/.local/share/owlen/sessions - /// - Windows: %APPDATA%\owlen\sessions - /// - macOS: ~/Library/Application Support/owlen/sessions - pub fn conversation_path(&self) -> PathBuf { - if let Some(ref dir) = self.conversation_dir { - PathBuf::from(shellexpand::tilde(dir).as_ref()) - } else { - // Use platform-specific data directory - dirs::data_local_dir() - .map(|d| d.join("owlen").join("sessions")) - .unwrap_or_else(|| PathBuf::from("./owlen_sessions")) - } - } -} - -impl Default for StorageSettings { - fn default() -> Self { - Self { - conversation_dir: None, // Use platform-specific defaults - auto_save_sessions: Self::default_auto_save(), - max_saved_sessions: Self::default_max_sessions(), - session_timeout_minutes: Self::default_session_timeout(), - generate_descriptions: Self::default_generate_descriptions(), - } - } -} - -/// Input handling preferences shared across clients -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InputSettings { - #[serde(default = "InputSettings::default_multiline")] - pub multiline: bool, - #[serde(default = "InputSettings::default_history_size")] - pub history_size: usize, - #[serde(default = "InputSettings::default_tab_width")] - pub tab_width: u8, - #[serde(default = "InputSettings::default_confirm_send")] - pub confirm_send: bool, -} - -impl InputSettings { - fn default_multiline() -> bool { - true - } - - fn default_history_size() -> usize { - 100 - } - - fn default_tab_width() -> u8 { - 4 - } - - fn default_confirm_send() -> bool { - false - } -} - -impl Default for InputSettings { - fn default() -> Self { - Self { - multiline: Self::default_multiline(), - history_size: Self::default_history_size(), - tab_width: Self::default_tab_width(), - confirm_send: Self::default_confirm_send(), - } - } -} - -/// Convenience accessor for an Ollama provider entry, creating a default if missing -pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig { - ensure_provider_config(config, "ollama_local") -} - -/// Ensure a provider configuration exists for the requested provider name and return a mutable reference. -pub fn ensure_provider_config_mut<'a>( - config: &'a mut Config, - provider_name: &str, -) -> &'a mut ProviderConfig { - let key = normalize_provider_key(provider_name); - let entry = config.providers.entry(key.clone()).or_insert_with(|| { - let mut default = default_provider_config_for(&key).unwrap_or_else(|| ProviderConfig { - enabled: true, - provider_type: canonical_provider_type(&key), - base_url: None, - api_key: None, - api_key_env: None, - extra: HashMap::new(), - }); - if default.provider_type.is_empty() { - default.provider_type = canonical_provider_type(&key); - } - default - }); - - if entry.provider_type.is_empty() { - entry.provider_type = canonical_provider_type(&key); - } - - entry -} - -/// Ensure a provider configuration exists for the requested provider name -pub fn ensure_provider_config<'a>( - config: &'a mut Config, - provider_name: &str, -) -> &'a ProviderConfig { - let entry = ensure_provider_config_mut(config, provider_name); - &*entry -} - -/// Calculate absolute timeout for session data based on configuration -pub fn session_timeout(config: &Config) -> Duration { - Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn expand_provider_env_vars_resolves_api_key() { - unsafe { - std::env::set_var("OWLEN_TEST_API_KEY", "super-secret"); - } - - let mut config = Config::default(); - if let Some(ollama_local) = config.providers.get_mut("ollama_local") { - ollama_local.api_key = Some("${OWLEN_TEST_API_KEY}".to_string()); - } - - config - .expand_provider_env_vars() - .expect("environment expansion succeeded"); - - assert_eq!( - config.providers["ollama_local"].api_key.as_deref(), - Some("super-secret") - ); - - unsafe { - std::env::remove_var("OWLEN_TEST_API_KEY"); - } - } - - #[test] - fn expand_provider_env_vars_errors_for_missing_variable() { - unsafe { - std::env::remove_var("OWLEN_TEST_MISSING"); - } - - let mut config = Config::default(); - if let Some(ollama_local) = config.providers.get_mut("ollama_local") { - ollama_local.api_key = Some("${OWLEN_TEST_MISSING}".to_string()); - } - - let error = config - .expand_provider_env_vars() - .expect_err("missing variables should error"); - - match error { - crate::Error::Config(message) => { - assert!(message.contains("OWLEN_TEST_MISSING")); - } - other => panic!("expected config error, got {other:?}"), - } - } - - #[test] - fn default_config_sets_provider_extras() { - let config = Config::default(); - let local = config - .providers - .get("ollama_local") - .expect("local provider"); - assert_eq!( - local - .extra - .get("list_ttl_secs") - .and_then(|value| value.as_u64()), - Some(DEFAULT_PROVIDER_LIST_TTL_SECS) - ); - assert_eq!( - local - .extra - .get("default_context_window") - .and_then(|value| value.as_u64()), - Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) - ); - - let cloud = config - .providers - .get("ollama_cloud") - .expect("cloud provider"); - assert_eq!( - cloud - .extra - .get("list_ttl_secs") - .and_then(|value| value.as_u64()), - Some(DEFAULT_PROVIDER_LIST_TTL_SECS) - ); - assert_eq!( - cloud - .extra - .get("default_context_window") - .and_then(|value| value.as_u64()), - Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) - ); - assert_eq!( - cloud - .extra - .get("hourly_quota_tokens") - .and_then(|value| value.as_u64()), - Some(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA) - ); - assert_eq!( - cloud - .extra - .get("weekly_quota_tokens") - .and_then(|value| value.as_u64()), - Some(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA) - ); - assert_eq!( - cloud - .extra - .get(OLLAMA_CLOUD_ENDPOINT_KEY) - .and_then(|value| value.as_str()), - Some(OLLAMA_CLOUD_BASE_URL) - ); - } - - #[test] - fn ensure_defaults_backfills_missing_provider_metadata() { - let mut config = Config::default(); - - if let Some(local) = config.providers.get_mut("ollama_local") { - local.extra.remove("list_ttl_secs"); - local.extra.insert( - "default_context_window".into(), - serde_json::Value::String("".into()), - ); - local.provider_type.clear(); - } - - if let Some(cloud) = config.providers.get_mut("ollama_cloud") { - cloud.extra.remove("list_ttl_secs"); - cloud.extra.insert( - "default_context_window".into(), - serde_json::Value::String("invalid".into()), - ); - cloud.extra.remove("hourly_quota_tokens"); - cloud.extra.remove("weekly_quota_tokens"); - cloud.extra.remove(OLLAMA_CLOUD_ENDPOINT_KEY); - cloud.api_key_env = None; - cloud.provider_type.clear(); - cloud.base_url = Some(LEGACY_OLLAMA_CLOUD_BASE_URL.to_string()); - } - - config.ensure_defaults(); - - let local = config - .providers - .get("ollama_local") - .expect("local provider"); - assert_eq!(local.provider_type, "ollama"); - assert_eq!( - local - .extra - .get("list_ttl_secs") - .and_then(|value| value.as_u64()), - Some(DEFAULT_PROVIDER_LIST_TTL_SECS) - ); - assert_eq!( - local - .extra - .get("default_context_window") - .and_then(|value| value.as_u64()), - Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) - ); - - let cloud = config - .providers - .get("ollama_cloud") - .expect("cloud provider"); - assert_eq!(cloud.provider_type, "ollama_cloud"); - assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); - assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); - assert_eq!( - cloud - .extra - .get("list_ttl_secs") - .and_then(|value| value.as_u64()), - Some(DEFAULT_PROVIDER_LIST_TTL_SECS) - ); - assert_eq!( - cloud - .extra - .get("default_context_window") - .and_then(|value| value.as_u64()), - Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) - ); - assert_eq!( - cloud - .extra - .get("hourly_quota_tokens") - .and_then(|value| value.as_u64()), - Some(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA) - ); - assert_eq!( - cloud - .extra - .get("weekly_quota_tokens") - .and_then(|value| value.as_u64()), - Some(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA) - ); - assert_eq!( - cloud - .extra - .get(OLLAMA_CLOUD_ENDPOINT_KEY) - .and_then(|value| value.as_str()), - Some(OLLAMA_CLOUD_BASE_URL) - ); - } - - #[test] - fn test_storage_platform_specific_paths() { - let config = Config::default(); - let path = config.storage.conversation_path(); - - // Verify it contains owlen/sessions - assert!(path.to_string_lossy().contains("owlen")); - assert!(path.to_string_lossy().contains("sessions")); - - // Platform-specific checks - #[cfg(target_os = "linux")] - { - // Linux should use ~/.local/share/owlen/sessions - assert!(path.to_string_lossy().contains(".local/share")); - } - - #[cfg(target_os = "windows")] - { - // Windows should use AppData - assert!(path.to_string_lossy().contains("AppData")); - } - - #[cfg(target_os = "macos")] - { - // macOS should use ~/Library/Application Support - assert!( - path.to_string_lossy() - .contains("Library/Application Support") - ); - } - - println!("Config conversation path: {}", path.display()); - } - - #[test] - fn test_storage_custom_path() { - let mut config = Config::default(); - config.storage.conversation_dir = Some("~/custom/path".to_string()); - - let path = config.storage.conversation_path(); - assert!(path.to_string_lossy().contains("custom/path")); - } - - #[test] - fn default_config_contains_local_provider() { - let config = Config::default(); - let local = config - .providers - .get("ollama_local") - .expect("default local provider"); - assert!(local.enabled); - assert_eq!(local.base_url.as_deref(), Some(OLLAMA_LOCAL_BASE_URL)); - - let cloud = config - .providers - .get("ollama_cloud") - .expect("default cloud provider"); - assert!(!cloud.enabled); - assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); - } - - #[test] - fn default_ui_accessibility_flags_off() { - let config = Config::default(); - assert!(!config.ui.accessibility.high_contrast); - assert!(!config.ui.accessibility.reduced_chrome); - } - - #[test] - fn ensure_provider_config_aliases_cloud_defaults() { - let mut config = Config::default(); - config.providers.clear(); - let cloud = ensure_provider_config(&mut config, "ollama-cloud"); - assert_eq!(cloud.provider_type, "ollama_cloud"); - assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); - assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); - assert!(config.providers.contains_key("ollama_cloud")); - assert!(!config.providers.contains_key("ollama-cloud")); - } - - #[test] - fn migrate_ollama_cloud_underscore_key() { - let mut config = Config::default(); - config.providers.clear(); - config.providers.insert( - "ollama_cloud".to_string(), - ProviderConfig { - enabled: true, - provider_type: "ollama_cloud".to_string(), - base_url: Some("https://api.ollama.com".to_string()), - api_key: Some("secret".to_string()), - api_key_env: None, - extra: HashMap::new(), - }, - ); - - config.apply_schema_migrations("1.0.0"); - - assert!(config.providers.get("ollama_cloud").is_some()); - let cloud = config - .providers - .get("ollama_cloud") - .expect("migrated config"); - assert!(cloud.enabled); - assert_eq!(cloud.provider_type, "ollama_cloud"); - assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); - assert_eq!(cloud.api_key.as_deref(), Some("secret")); - } - - #[test] - fn migration_sets_cloud_mode_for_cloud_base() { - let mut config = Config::default(); - if let Some(ollama) = config.providers.get_mut("ollama_local") { - ollama.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); - } - - config.apply_schema_migrations("1.4.0"); - - let cloud = config - .providers - .get("ollama_cloud") - .expect("cloud provider created"); - assert!(cloud.enabled); - assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); - assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); - } - - #[test] - fn migrate_legacy_monolithic_ollama_entry() { - let mut config = Config::default(); - config.providers.clear(); - config.providers.insert( - "ollama".to_string(), - ProviderConfig { - enabled: true, - provider_type: "ollama".to_string(), - base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()), - api_key: None, - api_key_env: None, - extra: HashMap::new(), - }, - ); - - config.apply_schema_migrations("1.2.0"); - - let local = config - .providers - .get("ollama_local") - .expect("local provider migrated"); - assert!(local.enabled); - assert_eq!(local.base_url.as_deref(), Some(OLLAMA_LOCAL_BASE_URL)); - - let cloud = config - .providers - .get("ollama_cloud") - .expect("cloud provider placeholder"); - assert!(!cloud.enabled); - } - - #[test] - fn migrate_legacy_provider_tables_moves_top_level_entries() { - let mut document: toml::Value = toml::from_str( - r#" - [ollama] - base_url = "http://localhost:11434" - - [general] - default_provider = "ollama" - "#, - ) - .expect("valid inline config"); - - migrate_legacy_provider_tables(&mut document); - - let providers = document - .get("providers") - .and_then(|value| value.as_table()) - .expect("providers table present"); - assert!(providers.contains_key("ollama")); - assert!(providers["ollama"].get("base_url").is_some()); - assert!(document.get("ollama").is_none()); - } - - #[test] - fn validate_rejects_missing_default_provider() { - let mut config = Config::default(); - config.general.default_provider = "does-not-exist".to_string(); - let result = config.validate(); - assert!( - matches!(result, Err(crate::Error::Config(message)) if message.contains("Default provider")) - ); - } - - #[test] - fn validate_rejects_remote_only_without_servers() { - let mut config = Config::default(); - config.mcp.mode = McpMode::RemoteOnly; - config.mcp_servers.clear(); - let result = config.validate(); - assert!( - matches!(result, Err(crate::Error::Config(message)) if message.contains("remote_only")) - ); - } - - #[test] - fn validate_rejects_unknown_transport() { - let mut config = Config::default(); - config.mcp_servers = vec![McpServerConfig { - name: "bad".into(), - command: "binary".into(), - transport: "udp".into(), - args: Vec::new(), - env: std::collections::HashMap::new(), - oauth: None, - rpc_timeout_secs: None, - }]; - let result = config.validate(); - assert!( - matches!(result, Err(crate::Error::Config(message)) if message.contains("transport")) - ); - } - - #[test] - fn validate_accepts_local_only_configuration() { - let mut config = Config::default(); - config.mcp.mode = McpMode::LocalOnly; - assert!(config.validate().is_ok()); - } - - #[test] - fn refresh_mcp_servers_merges_scopes_with_precedence() { - let temp = tempfile::tempdir().expect("tempdir"); - let project_root = temp.path(); - std::fs::write( - project_root.join(".mcp.json"), - r#"{ - "servers": [ - { "name": "shared", "command": "project-cmd", "transport": "stdio" }, - { "name": "project-only", "command": "proj", "transport": "stdio" } - ], - "resources": [ - { "server": "github", "uri": "issue://123", "title": "Project Issue" }, - { "server": "docs", "uri": "page://start", "title": "Project Doc" } - ] - }"#, - ) - .expect("write project scope"); - - let local_dir = project_root.join(".owlen"); - std::fs::create_dir_all(&local_dir).expect("local dir"); - std::fs::write( - local_dir.join("mcp.local.json"), - r#"{ - "servers": [ - { "name": "shared", "command": "local-cmd", "transport": "stdio" } - ], - "resources": [ - { "server": "github", "uri": "issue://123", "title": "Local Override" } - ] - }"#, - ) - .expect("write local scope"); - - let mut config = Config::default(); - config.mcp_servers.push(McpServerConfig { - name: "shared".into(), - command: "user-cmd".into(), - args: Vec::new(), - transport: "stdio".into(), - env: std::collections::HashMap::new(), - oauth: None, - rpc_timeout_secs: None, - }); - config.mcp_resources.push(McpResourceConfig { - server: "github".into(), - uri: "issue://123".into(), - title: Some("User Issue".into()), - description: None, - }); - - config - .refresh_mcp_servers(Some(project_root)) - .expect("refresh scopes"); - - // We should have four scoped entries (user + two project + local) and precedence should select local - assert_eq!(config.scoped_mcp_servers().len(), 4); - let effective = config.effective_mcp_servers(); - assert_eq!(effective.len(), 2); // shared + project-only - assert_eq!(effective[0].command, "local-cmd"); - assert_eq!(effective[0].name, "shared"); - - assert_eq!(config.scoped_mcp_resources().len(), 4); - let effective_resources = config.effective_mcp_resources(); - assert_eq!(effective_resources.len(), 2); - assert_eq!( - effective_resources - .iter() - .find(|res| res.server == "github") - .and_then(|res| res.title.as_deref()), - Some("Local Override") - ); - } - - #[test] - fn remove_mcp_server_reports_scope() { - let temp = tempfile::tempdir().expect("tempdir"); - let project_root = temp.path(); - std::fs::write( - project_root.join(".mcp.json"), - r#"{ "servers": [{ "name": "project", "command": "proj", "transport": "stdio" }] }"#, - ) - .expect("write project scope"); - - let mut config = Config::default(); - config.mcp_servers.push(McpServerConfig { - name: "user".into(), - command: "user".into(), - args: Vec::new(), - transport: "stdio".into(), - env: std::collections::HashMap::new(), - oauth: None, - rpc_timeout_secs: None, - }); - config - .refresh_mcp_servers(Some(project_root)) - .expect("refresh scopes"); - - // Remove without specifying scope should pick highest precedence (project) - let removed_scope = config - .remove_mcp_server(None, "project", Some(project_root)) - .expect("remove call"); - assert_eq!(removed_scope, Some(McpConfigScope::Project)); - - // Remove the remaining user scope explicitly - let removed_scope = config - .remove_mcp_server(Some(McpConfigScope::User), "user", Some(project_root)) - .expect("remove user"); - assert_eq!(removed_scope, Some(McpConfigScope::User)); - } -} diff --git a/crates/owlen-core/src/consent.rs b/crates/owlen-core/src/consent.rs deleted file mode 100644 index 5347e5f..0000000 --- a/crates/owlen-core/src/consent.rs +++ /dev/null @@ -1,312 +0,0 @@ -use std::collections::HashMap; -use std::io::{self, Write}; -use std::sync::Arc; - -use anyhow::Result; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::encryption::VaultHandle; -use crate::tools::canonical_tool_name; - -#[derive(Clone, Debug)] -pub struct ConsentRequest { - pub tool_name: String, -} - -/// Scope of consent grant -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub enum ConsentScope { - /// Grant only for this single operation - Once, - /// Grant for the duration of the current session - Session, - /// Grant permanently (persisted across sessions) - Permanent, - /// Explicitly denied - Denied, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct ConsentRecord { - pub tool_name: String, - pub scope: ConsentScope, - pub timestamp: DateTime, - pub data_types: Vec, - pub external_endpoints: Vec, -} - -#[derive(Serialize, Deserialize, Default)] -pub struct ConsentManager { - /// Permanent consent records (persisted to vault) - permanent_records: HashMap, - /// Session-scoped consent (cleared on manager drop or explicit clear) - #[serde(skip)] - session_records: HashMap, - /// Once-scoped consent (used once then cleared) - #[serde(skip)] - once_records: HashMap, - /// Pending consent requests (to prevent duplicate prompts) - #[serde(skip)] - pending_requests: HashMap, -} - -impl ConsentManager { - pub fn new() -> Self { - Self::default() - } - - /// Load consent records from vault storage - pub fn from_vault(vault: &Arc>) -> Self { - let guard = vault.lock().expect("Vault mutex poisoned"); - if let Some(permanent_records) = - guard - .settings() - .get("consent_records") - .and_then(|consent_data| { - serde_json::from_value::>(consent_data.clone()) - .ok() - }) - { - return Self { - permanent_records, - session_records: HashMap::new(), - once_records: HashMap::new(), - pending_requests: HashMap::new(), - }; - } - Self::default() - } - - /// Persist permanent consent records to vault storage - pub fn persist_to_vault(&self, vault: &Arc>) -> Result<()> { - let mut guard = vault.lock().expect("Vault mutex poisoned"); - let consent_json = serde_json::to_value(&self.permanent_records)?; - guard - .settings_mut() - .insert("consent_records".to_string(), consent_json); - guard.persist()?; - Ok(()) - } - - pub fn request_consent( - &mut self, - tool_name: &str, - data_types: Vec, - endpoints: Vec, - ) -> Result { - let canonical = canonical_tool_name(tool_name); - - // Check if already granted permanently - if self - .permanent_records - .get(canonical) - .is_some_and(|existing| existing.scope == ConsentScope::Permanent) - { - return Ok(ConsentScope::Permanent); - } - - // Check if granted for session - if self - .session_records - .get(canonical) - .is_some_and(|existing| existing.scope == ConsentScope::Session) - { - return Ok(ConsentScope::Session); - } - - // Check if request is already pending (prevent duplicate prompts) - if self.pending_requests.contains_key(canonical) { - // Wait for the other prompt to complete by returning denied temporarily - // The caller should retry after a short delay - return Ok(ConsentScope::Denied); - } - - // Mark as pending - self.pending_requests.insert(canonical.to_string(), ()); - - // Show consent dialog and get scope - let scope = self.show_consent_dialog(tool_name, &data_types, &endpoints)?; - - // Remove from pending - self.pending_requests.remove(canonical); - - // Create record based on scope - let record = ConsentRecord { - tool_name: canonical.to_string(), - scope: scope.clone(), - timestamp: Utc::now(), - data_types, - external_endpoints: endpoints, - }; - - // Store in appropriate location - match scope { - ConsentScope::Permanent => { - self.permanent_records.insert(canonical.to_string(), record); - } - ConsentScope::Session => { - self.session_records.insert(canonical.to_string(), record); - } - ConsentScope::Once | ConsentScope::Denied => { - // Don't store, just return the decision - } - } - - Ok(scope) - } - - /// Grant consent programmatically (for TUI or automated flows) - pub fn grant_consent( - &mut self, - tool_name: &str, - data_types: Vec, - endpoints: Vec, - ) { - self.grant_consent_with_scope(tool_name, data_types, endpoints, ConsentScope::Permanent); - } - - /// Grant consent with specific scope - pub fn grant_consent_with_scope( - &mut self, - tool_name: &str, - data_types: Vec, - endpoints: Vec, - scope: ConsentScope, - ) { - let canonical = canonical_tool_name(tool_name); - let record = ConsentRecord { - tool_name: canonical.to_string(), - scope: scope.clone(), - timestamp: Utc::now(), - data_types, - external_endpoints: endpoints, - }; - - match scope { - ConsentScope::Permanent => { - self.permanent_records.insert(canonical.to_string(), record); - } - ConsentScope::Session => { - self.session_records.insert(canonical.to_string(), record); - } - ConsentScope::Once => { - self.once_records.insert(canonical.to_string(), record); - } - ConsentScope::Denied => {} // Denied is not stored - } - } - - /// Check if consent is needed (returns None if already granted, Some(info) if needed) - pub fn check_consent_needed(&self, tool_name: &str) -> Option { - let canonical = canonical_tool_name(tool_name); - if self.has_consent(canonical) { - None - } else { - Some(ConsentRequest { - tool_name: canonical.to_string(), - }) - } - } - - pub fn has_consent(&self, tool_name: &str) -> bool { - let canonical = canonical_tool_name(tool_name); - // Check permanent first, then session, then once - self.permanent_records - .get(canonical) - .map(|r| r.scope == ConsentScope::Permanent) - .or_else(|| { - self.session_records - .get(canonical) - .map(|r| r.scope == ConsentScope::Session) - }) - .or_else(|| { - self.once_records - .get(canonical) - .map(|r| r.scope == ConsentScope::Once) - }) - .unwrap_or(false) - } - - /// Consume "once" consent for a tool (clears it after first use) - pub fn consume_once_consent(&mut self, tool_name: &str) { - let canonical = canonical_tool_name(tool_name); - self.once_records.remove(canonical); - } - - pub fn revoke_consent(&mut self, tool_name: &str) { - let canonical = canonical_tool_name(tool_name); - self.permanent_records.remove(canonical); - self.session_records.remove(canonical); - self.once_records.remove(canonical); - } - - pub fn clear_all_consent(&mut self) { - self.permanent_records.clear(); - self.session_records.clear(); - self.once_records.clear(); - } - - /// Clear only session-scoped consent (useful when starting new session) - pub fn clear_session_consent(&mut self) { - self.session_records.clear(); - self.once_records.clear(); // Also clear once consent on session clear - } - - /// Check if consent is needed for a tool (non-blocking) - /// Returns Some with consent details if needed, None if already granted - pub fn check_if_consent_needed( - &self, - tool_name: &str, - data_types: Vec, - endpoints: Vec, - ) -> Option<(String, Vec, Vec)> { - let canonical = canonical_tool_name(tool_name); - if self.has_consent(canonical) { - return None; - } - Some((canonical.to_string(), data_types, endpoints)) - } - - fn show_consent_dialog( - &self, - tool_name: &str, - data_types: &[String], - endpoints: &[String], - ) -> Result { - // TEMPORARY: Auto-grant session consent when not in a proper terminal (TUI mode) - // TODO: Integrate consent UI into the TUI event loop - use std::io::IsTerminal; - if !io::stdin().is_terminal() || std::env::var("OWLEN_AUTO_CONSENT").is_ok() { - eprintln!("Auto-granting session consent for {} (TUI mode)", tool_name); - return Ok(ConsentScope::Session); - } - - println!("\n╔══════════════════════════════════════════════════╗"); - println!("║ 🔒 PRIVACY CONSENT REQUIRED 🔒 ║"); - println!("╚══════════════════════════════════════════════════╝"); - println!(); - println!("Tool: {}", tool_name); - println!("Data: {}", data_types.join(", ")); - println!("Endpoints: {}", endpoints.join(", ")); - println!(); - println!("Choose consent scope:"); - println!(" [1] Allow once - Grant only for this operation"); - println!(" [2] Allow session - Grant for current session"); - println!(" [3] Allow always - Grant permanently"); - println!(" [4] Deny - Reject this operation"); - println!(); - print!("Enter choice (1-4) [default: 4]: "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - match input.trim() { - "1" => Ok(ConsentScope::Once), - "2" => Ok(ConsentScope::Session), - "3" => Ok(ConsentScope::Permanent), - _ => Ok(ConsentScope::Denied), - } - } -} diff --git a/crates/owlen-core/src/conversation.rs b/crates/owlen-core/src/conversation.rs deleted file mode 100644 index 117f88f..0000000 --- a/crates/owlen-core/src/conversation.rs +++ /dev/null @@ -1,448 +0,0 @@ -use crate::Result; -use crate::storage::StorageManager; -use crate::types::{Conversation, Message, MessageAttachment}; -use serde_json::{Number, Value}; -use std::collections::{HashMap, VecDeque}; -use std::time::{Duration, Instant}; -use uuid::Uuid; - -const STREAMING_FLAG: &str = "streaming"; -const LAST_CHUNK_TS: &str = "last_chunk_ts"; -const PLACEHOLDER_FLAG: &str = "placeholder"; - -/// Manage active and historical conversations, including streaming updates. -pub struct ConversationManager { - active: Conversation, - history: VecDeque, - message_index: HashMap, - streaming: HashMap, - max_history: usize, -} - -#[derive(Debug, Clone)] -pub struct StreamingMetadata { - started: Instant, - last_update: Instant, -} - -impl ConversationManager { - /// Create a new conversation manager with a default model - pub fn new(model: impl Into) -> Self { - Self::with_history_capacity(model, 32) - } - - /// Create with explicit history capacity - pub fn with_history_capacity(model: impl Into, max_history: usize) -> Self { - let conversation = Conversation::new(model.into()); - Self { - active: conversation, - history: VecDeque::new(), - message_index: HashMap::new(), - streaming: HashMap::new(), - max_history: max_history.max(1), - } - } - - /// Access the active conversation - pub fn active(&self) -> &Conversation { - &self.active - } - - /// Public mutable access to the active conversation - pub fn active_mut(&mut self) -> &mut Conversation { - &mut self.active - } - - /// Replace the active conversation with a provided one, archiving the existing conversation if it contains data - pub fn load(&mut self, conversation: Conversation) { - if !self.active.messages.is_empty() { - self.archive_active(); - } - - self.message_index.clear(); - for (idx, message) in conversation.messages.iter().enumerate() { - self.message_index.insert(message.id, idx); - } - - self.stream_reset(); - self.active = conversation; - } - - /// Start a brand new conversation, archiving the previous one - pub fn start_new(&mut self, model: Option, name: Option) { - self.archive_active(); - let model = model.unwrap_or_else(|| self.active.model.clone()); - self.active = Conversation::new(model); - self.active.name = name; - self.message_index.clear(); - self.stream_reset(); - } - - /// Archive the active conversation into history - pub fn archive_active(&mut self) { - if self.active.messages.is_empty() { - return; - } - - let mut archived = self.active.clone(); - archived.updated_at = std::time::SystemTime::now(); - self.history.push_front(archived); - - while self.history.len() > self.max_history { - self.history.pop_back(); - } - } - - /// Get immutable history - pub fn history(&self) -> impl Iterator { - self.history.iter() - } - - /// Add a user message and return its identifier - pub fn push_user_message(&mut self, content: impl Into) -> Uuid { - let message = Message::user(content.into()); - self.register_message(message) - } - - /// Add a user message that includes rich attachments. - pub fn push_user_message_with_attachments( - &mut self, - content: impl Into, - attachments: Vec, - ) -> Uuid { - let message = Message::user(content.into()).with_attachments(attachments); - self.register_message(message) - } - - /// Add a system message and return its identifier - pub fn push_system_message(&mut self, content: impl Into) -> Uuid { - let message = Message::system(content.into()); - self.register_message(message) - } - - /// Add an assistant message (non-streaming) and return its identifier - pub fn push_assistant_message(&mut self, content: impl Into) -> Uuid { - let message = Message::assistant(content.into()); - self.register_message(message) - } - - /// Push an arbitrary message into the active conversation - pub fn push_message(&mut self, message: Message) -> Uuid { - self.register_message(message) - } - - /// Start tracking a streaming assistant response, returning the message id to update - pub fn start_streaming_response(&mut self) -> Uuid { - let mut message = Message::assistant(String::new()); - message - .metadata - .insert(STREAMING_FLAG.to_string(), Value::Bool(true)); - let id = message.id; - self.register_message(message); - self.streaming.insert( - id, - StreamingMetadata { - started: Instant::now(), - last_update: Instant::now(), - }, - ); - id - } - - /// Append streaming content to an assistant message - pub fn append_stream_chunk( - &mut self, - message_id: Uuid, - chunk: &str, - is_final: bool, - ) -> Result<()> { - let index = self - .message_index - .get(&message_id) - .copied() - .ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?; - - let conversation = self.active_mut(); - if let Some(message) = conversation.messages.get_mut(index) { - let was_placeholder = message - .metadata - .remove(PLACEHOLDER_FLAG) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if was_placeholder { - message.content.clear(); - } - - if !chunk.is_empty() { - message.content.push_str(chunk); - } - message.timestamp = std::time::SystemTime::now(); - let millis = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - message.metadata.insert( - LAST_CHUNK_TS.to_string(), - Value::Number(Number::from(millis)), - ); - - if is_final { - message - .metadata - .insert(STREAMING_FLAG.to_string(), Value::Bool(false)); - self.streaming.remove(&message_id); - } else if let Some(info) = self.streaming.get_mut(&message_id) { - info.last_update = Instant::now(); - } - } - - Ok(()) - } - - /// Replace the current streaming content for a message. - pub fn set_stream_content( - &mut self, - message_id: Uuid, - content: impl Into, - is_final: bool, - ) -> Result<()> { - let index = self - .message_index - .get(&message_id) - .copied() - .ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?; - - let conversation = self.active_mut(); - if let Some(message) = conversation.messages.get_mut(index) { - message.content = content.into(); - message.metadata.remove(PLACEHOLDER_FLAG); - message.timestamp = std::time::SystemTime::now(); - let millis = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - message.metadata.insert( - LAST_CHUNK_TS.to_string(), - Value::Number(Number::from(millis)), - ); - - if is_final { - message - .metadata - .insert(STREAMING_FLAG.to_string(), Value::Bool(false)); - self.streaming.remove(&message_id); - } else if let Some(info) = self.streaming.get_mut(&message_id) { - info.last_update = Instant::now(); - } - } - - Ok(()) - } - - /// Set placeholder text for a streaming message - pub fn set_stream_placeholder( - &mut self, - message_id: Uuid, - text: impl Into, - ) -> Result<()> { - let index = self - .message_index - .get(&message_id) - .copied() - .ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?; - - if let Some(message) = self.active_mut().messages.get_mut(index) { - message.content = text.into(); - message.timestamp = std::time::SystemTime::now(); - message - .metadata - .insert(PLACEHOLDER_FLAG.to_string(), Value::Bool(true)); - } - - Ok(()) - } - - pub fn cancel_stream(&mut self, message_id: Uuid, notice: impl Into) -> Result<()> { - let index = self - .message_index - .get(&message_id) - .copied() - .ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?; - - if let Some(message) = self.active_mut().messages.get_mut(index) { - message.content = notice.into(); - message.timestamp = std::time::SystemTime::now(); - message - .metadata - .insert(STREAMING_FLAG.to_string(), Value::Bool(false)); - message.metadata.remove(PLACEHOLDER_FLAG); - let millis = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - message.metadata.insert( - LAST_CHUNK_TS.to_string(), - Value::Number(Number::from(millis)), - ); - } - - self.streaming.remove(&message_id); - Ok(()) - } - - /// Set tool calls on a streaming message - pub fn set_tool_calls_on_message( - &mut self, - message_id: Uuid, - tool_calls: Vec, - ) -> Result<()> { - let index = self - .message_index - .get(&message_id) - .copied() - .ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?; - - if let Some(message) = self.active_mut().messages.get_mut(index) { - if tool_calls.is_empty() { - message.tool_calls = None; - } else { - message.tool_calls = Some(tool_calls); - } - } - - Ok(()) - } - - /// Update the active model (used when user changes model mid session) - pub fn set_model(&mut self, model: impl Into) { - self.active.model = model.into(); - self.active.updated_at = std::time::SystemTime::now(); - } - - /// Provide read access to the cached streaming metadata - pub fn streaming_metadata(&self, message_id: &Uuid) -> Option { - self.streaming.get(message_id).cloned() - } - - /// Remove inactive streaming messages that have stalled beyond the provided timeout - pub fn expire_stalled_streams(&mut self, idle_timeout: Duration) -> Vec { - let cutoff = Instant::now() - idle_timeout; - let mut expired = Vec::new(); - - self.streaming.retain(|id, meta| { - if meta.last_update < cutoff { - expired.push(*id); - false - } else { - true - } - }); - - expired - } - - /// Clear all state - pub fn clear(&mut self) { - self.active.clear(); - self.history.clear(); - self.message_index.clear(); - self.streaming.clear(); - } - - fn register_message(&mut self, message: Message) -> Uuid { - let id = message.id; - let idx; - { - let conversation = self.active_mut(); - idx = conversation.messages.len(); - conversation.messages.push(message); - conversation.updated_at = std::time::SystemTime::now(); - } - self.message_index.insert(id, idx); - id - } - - /// Replace the active conversation messages and rebuild internal indexes. - pub fn replace_active_messages(&mut self, mut messages: Vec) { - let now = std::time::SystemTime::now(); - for message in &mut messages { - // Ensure message timestamps are not in the far past when rewired. - message.timestamp = now; - } - self.active.messages = messages; - self.active.updated_at = now; - self.rebuild_index(); - self.stream_reset(); - } - - fn rebuild_index(&mut self) { - self.message_index.clear(); - for (idx, message) in self.active.messages.iter().enumerate() { - self.message_index.insert(message.id, idx); - } - } - - fn stream_reset(&mut self) { - self.streaming.clear(); - } - - /// Save the active conversation to disk - pub async fn save_active( - &self, - storage: &StorageManager, - name: Option, - ) -> Result { - storage.save_conversation(&self.active, name).await?; - Ok(self.active.id) - } - - /// Save the active conversation to disk with a description - pub async fn save_active_with_description( - &self, - storage: &StorageManager, - name: Option, - description: Option, - ) -> Result { - storage - .save_conversation_with_description(&self.active, name, description) - .await?; - Ok(self.active.id) - } - - /// Load a conversation from storage and make it active - pub async fn load_saved(&mut self, storage: &StorageManager, id: Uuid) -> Result<()> { - let conversation = storage.load_conversation(id).await?; - self.load(conversation); - Ok(()) - } - - /// List all saved sessions - pub async fn list_saved_sessions( - storage: &StorageManager, - ) -> Result> { - storage.list_sessions().await - } -} - -impl StreamingMetadata { - /// Duration since the stream started - pub fn elapsed(&self) -> Duration { - self.started.elapsed() - } - - /// Duration since the last chunk was received - pub fn idle_duration(&self) -> Duration { - self.last_update.elapsed() - } - - /// Timestamp when streaming started - pub fn started_at(&self) -> Instant { - self.started - } - - /// Timestamp of most recent update - pub fn last_update_at(&self) -> Instant { - self.last_update - } -} diff --git a/crates/owlen-core/src/credentials.rs b/crates/owlen-core/src/credentials.rs deleted file mode 100644 index 8fa5fb4..0000000 --- a/crates/owlen-core/src/credentials.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::sync::Arc; - -use serde::{Deserialize, Serialize}; - -use crate::{Error, Result, oauth::OAuthToken, storage::StorageManager}; - -#[derive(Serialize, Deserialize, Debug)] -pub struct ApiCredentials { - pub api_key: String, - pub endpoint: String, -} - -pub const OLLAMA_CLOUD_CREDENTIAL_ID: &str = "provider_ollama_cloud"; - -pub struct CredentialManager { - storage: Arc, - master_key: Arc>, - namespace: String, -} - -impl CredentialManager { - pub fn new(storage: Arc, master_key: Arc>) -> Self { - Self { - storage, - master_key, - namespace: "owlen".to_string(), - } - } - - fn namespaced_key(&self, tool_name: &str) -> String { - format!("{}_{}", self.namespace, tool_name) - } - - fn oauth_storage_key(&self, resource: &str) -> String { - self.namespaced_key(&format!("oauth_{resource}")) - } - - pub async fn store_credentials( - &self, - tool_name: &str, - credentials: &ApiCredentials, - ) -> Result<()> { - let key = self.namespaced_key(tool_name); - let payload = serde_json::to_vec(credentials).map_err(|e| { - Error::Storage(format!( - "Failed to serialize credentials for secure storage: {e}" - )) - })?; - self.storage - .store_secure_item(&key, &payload, &self.master_key) - .await - } - - pub async fn get_credentials(&self, tool_name: &str) -> Result> { - let key = self.namespaced_key(tool_name); - match self - .storage - .load_secure_item(&key, &self.master_key) - .await? - { - Some(bytes) => { - let creds = serde_json::from_slice(&bytes).map_err(|e| { - Error::Storage(format!("Failed to deserialize stored credentials: {e}")) - })?; - Ok(Some(creds)) - } - None => Ok(None), - } - } - - pub async fn delete_credentials(&self, tool_name: &str) -> Result<()> { - let key = self.namespaced_key(tool_name); - self.storage.delete_secure_item(&key).await - } - - pub async fn store_oauth_token(&self, resource: &str, token: &OAuthToken) -> Result<()> { - let key = self.oauth_storage_key(resource); - let payload = serde_json::to_vec(token).map_err(|err| { - Error::Storage(format!( - "Failed to serialize OAuth token for secure storage: {err}" - )) - })?; - self.storage - .store_secure_item(&key, &payload, &self.master_key) - .await - } - - pub async fn load_oauth_token(&self, resource: &str) -> Result> { - let key = self.oauth_storage_key(resource); - let raw = self - .storage - .load_secure_item(&key, &self.master_key) - .await?; - if let Some(bytes) = raw { - let token = serde_json::from_slice(&bytes).map_err(|err| { - Error::Storage(format!("Failed to deserialize stored OAuth token: {err}")) - })?; - Ok(Some(token)) - } else { - Ok(None) - } - } - - pub async fn delete_oauth_token(&self, resource: &str) -> Result<()> { - let key = self.oauth_storage_key(resource); - self.storage.delete_secure_item(&key).await - } -} diff --git a/crates/owlen-core/src/encryption.rs b/crates/owlen-core/src/encryption.rs deleted file mode 100644 index 6d0cca5..0000000 --- a/crates/owlen-core/src/encryption.rs +++ /dev/null @@ -1,265 +0,0 @@ -// TODO: Upgrade to generic-array 1.x to remove deprecation warnings -#![allow(deprecated)] - -use std::collections::HashMap; -use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; - -use aes_gcm::{ - Aes256Gcm, Nonce, - aead::{Aead, KeyInit}, -}; -use anyhow::{Context, Result, bail}; -use ring::rand::{SecureRandom, SystemRandom}; -use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; - -pub struct EncryptedStorage { - cipher: Aes256Gcm, - storage_path: PathBuf, -} - -#[derive(Serialize, Deserialize)] -struct EncryptedData { - nonce: [u8; 12], - ciphertext: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct VaultData { - pub master_key: Vec, - #[serde(default)] - pub settings: HashMap, -} - -pub struct VaultHandle { - storage: EncryptedStorage, - pub data: VaultData, -} - -impl VaultHandle { - pub fn master_key(&self) -> &[u8] { - &self.data.master_key - } - - pub fn settings(&self) -> &HashMap { - &self.data.settings - } - - pub fn settings_mut(&mut self) -> &mut HashMap { - &mut self.data.settings - } - - pub fn persist(&self) -> Result<()> { - self.storage.store(&self.data) - } -} - -impl EncryptedStorage { - pub fn new(storage_path: PathBuf, key: &[u8]) -> Result { - if key.len() != 32 { - bail!( - "Invalid key length for encrypted storage ({}). Expected 32 bytes for AES-256.", - key.len() - ); - } - let cipher = Aes256Gcm::new_from_slice(key) - .map_err(|_| anyhow::anyhow!("Invalid key length for AES-256"))?; - - if let Some(parent) = storage_path.parent() { - fs::create_dir_all(parent).context("Failed to ensure storage directory exists")?; - } - - Ok(Self { - cipher, - storage_path, - }) - } - - pub fn store(&self, data: &T) -> Result<()> { - let json = serde_json::to_vec(data).context("Failed to serialize data")?; - - let nonce = generate_nonce()?; - let nonce_ref = Nonce::from_slice(&nonce); - - let ciphertext = self - .cipher - .encrypt(nonce_ref, json.as_ref()) - .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?; - - let encrypted_data = EncryptedData { nonce, ciphertext }; - let encrypted_json = serde_json::to_vec(&encrypted_data)?; - - fs::write(&self.storage_path, encrypted_json).context("Failed to write encrypted data")?; - - Ok(()) - } - - pub fn load Deserialize<'de>>(&self) -> Result { - let encrypted_json = - fs::read(&self.storage_path).context("Failed to read encrypted data")?; - - let encrypted_data: EncryptedData = - serde_json::from_slice(&encrypted_json).context("Failed to parse encrypted data")?; - - let nonce_ref = Nonce::from_slice(&encrypted_data.nonce); - let plaintext = self - .cipher - .decrypt(nonce_ref, encrypted_data.ciphertext.as_ref()) - .map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))?; - - let data: T = - serde_json::from_slice(&plaintext).context("Failed to deserialize decrypted data")?; - - Ok(data) - } - - pub fn exists(&self) -> bool { - self.storage_path.exists() - } - - pub fn delete(&self) -> Result<()> { - if self.exists() { - fs::remove_file(&self.storage_path).context("Failed to delete encrypted storage")?; - } - Ok(()) - } - - pub fn verify_password(&self) -> Result<()> { - if !self.exists() { - return Ok(()); - } - - let encrypted_json = - fs::read(&self.storage_path).context("Failed to read encrypted data")?; - - if encrypted_json.is_empty() { - return Ok(()); - } - - let encrypted_data: EncryptedData = - serde_json::from_slice(&encrypted_json).context("Failed to parse encrypted data")?; - - let nonce_ref = Nonce::from_slice(&encrypted_data.nonce); - self.cipher - .decrypt(nonce_ref, encrypted_data.ciphertext.as_ref()) - .map(|_| ()) - .map_err(|e| anyhow::anyhow!("Decryption failed: {}", e)) - } -} - -pub fn unlock(storage_path: PathBuf) -> Result { - let key = load_or_create_encryption_key(&storage_path)?; - let storage = EncryptedStorage::new(storage_path, &key)?; - let data = load_or_initialize_vault(&storage)?; - Ok(VaultHandle { storage, data }) -} - -fn load_or_initialize_vault(storage: &EncryptedStorage) -> Result { - match storage.load::() { - Ok(data) => { - if data.master_key.len() != 32 { - bail!( - "Corrupted vault: master key has invalid length ({}). \ - Expected 32 bytes for AES-256. Vault cannot be recovered.", - data.master_key.len() - ); - } - Ok(data) - } - Err(err) => { - if storage.exists() { - return Err(err); - } - let data = VaultData { - master_key: generate_master_key()?, - ..Default::default() - }; - storage.store(&data)?; - Ok(data) - } - } -} - -fn key_path(storage_path: &Path) -> PathBuf { - let mut path = storage_path.to_path_buf(); - path.set_extension("key"); - path -} - -fn load_or_create_encryption_key(storage_path: &Path) -> Result> { - let key_path = key_path(storage_path); - match fs::read(&key_path) { - Ok(bytes) => { - if bytes.len() == 32 { - Ok(bytes) - } else { - bail!( - "Invalid encryption key length stored in {} ({} bytes). Expected 32 bytes.", - key_path.display(), - bytes.len() - ); - } - } - Err(err) if err.kind() == io::ErrorKind::NotFound => { - let key = generate_master_key()?; - write_key_file(&key_path, &key)?; - Ok(key) - } - Err(err) => Err(err) - .with_context(|| format!("Failed to read encryption key from {}", key_path.display())), - } -} - -fn write_key_file(path: &Path, key: &[u8]) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directory {}", parent.display()))?; - } - - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - - let mut file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .mode(0o600) - .open(path) - .with_context(|| format!("Failed to open encryption key file {}", path.display()))?; - file.write_all(key) - .with_context(|| format!("Failed to write encryption key file {}", path.display()))?; - } - - #[cfg(not(unix))] - { - let mut file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(path) - .with_context(|| format!("Failed to open encryption key file {}", path.display()))?; - file.write_all(key) - .with_context(|| format!("Failed to write encryption key file {}", path.display()))?; - } - - Ok(()) -} - -fn generate_master_key() -> Result> { - let mut key = vec![0u8; 32]; - SystemRandom::new() - .fill(&mut key) - .map_err(|_| anyhow::anyhow!("Failed to generate master key"))?; - Ok(key) -} - -fn generate_nonce() -> Result<[u8; 12]> { - let mut nonce = [0u8; 12]; - let rng = SystemRandom::new(); - rng.fill(&mut nonce) - .map_err(|_| anyhow::anyhow!("Failed to generate nonce"))?; - Ok(nonce) -} diff --git a/crates/owlen-core/src/facade/llm_client.rs b/crates/owlen-core/src/facade/llm_client.rs deleted file mode 100644 index 2c64f2d..0000000 --- a/crates/owlen-core/src/facade/llm_client.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; - -use crate::{ - Result, - llm::ChatStream, - mcp::{McpToolCall, McpToolDescriptor, McpToolResponse}, - types::{ChatRequest, ChatResponse, ModelInfo}, -}; - -/// Object-safe facade for interacting with LLM backends. -#[async_trait] -pub trait LlmClient: Send + Sync { - /// List the models exposed by this client. - async fn list_models(&self) -> Result>; - - /// Issue a one-shot chat request and wait for the complete response. - async fn send_chat(&self, request: ChatRequest) -> Result; - - /// Stream chat responses incrementally. - async fn stream_chat(&self, request: ChatRequest) -> Result; - - /// Enumerate tools exposed by the backing provider. - async fn list_tools(&self) -> Result>; - - /// Invoke a tool exposed by the provider. - async fn call_tool(&self, call: McpToolCall) -> Result; -} - -/// Convenience alias for trait-object clients. -pub type DynLlmClient = Arc; diff --git a/crates/owlen-core/src/facade/mod.rs b/crates/owlen-core/src/facade/mod.rs deleted file mode 100644 index 23952d0..0000000 --- a/crates/owlen-core/src/facade/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod llm_client; diff --git a/crates/owlen-core/src/formatting.rs b/crates/owlen-core/src/formatting.rs deleted file mode 100644 index 65ad786..0000000 --- a/crates/owlen-core/src/formatting.rs +++ /dev/null @@ -1,112 +0,0 @@ -use crate::types::Message; -use crate::ui::RoleLabelDisplay; - -/// Formats messages for display across different clients. -#[derive(Debug, Clone)] -pub struct MessageFormatter { - wrap_width: usize, - role_label_mode: RoleLabelDisplay, - preserve_empty_lines: bool, -} - -impl MessageFormatter { - /// Create a new formatter - pub fn new(wrap_width: usize, role_label_mode: RoleLabelDisplay) -> Self { - Self { - wrap_width: wrap_width.max(20), - role_label_mode, - preserve_empty_lines: false, - } - } - - /// Override whether empty lines should be preserved - pub fn with_preserve_empty(mut self, preserve: bool) -> Self { - self.preserve_empty_lines = preserve; - self - } - - /// Update the wrap width - pub fn set_wrap_width(&mut self, width: usize) { - self.wrap_width = width.max(20); - } - - /// The configured role label layout preference. - pub fn role_label_mode(&self) -> RoleLabelDisplay { - self.role_label_mode - } - - /// Whether any role label should be shown alongside messages. - pub fn show_role_labels(&self) -> bool { - !matches!(self.role_label_mode, RoleLabelDisplay::None) - } - - /// Update the role label layout preference. - pub fn set_role_label_mode(&mut self, mode: RoleLabelDisplay) { - self.role_label_mode = mode; - } - - pub fn format_message(&self, message: &Message) -> Vec { - message - .content - .trim() - .lines() - .map(|s| s.to_string()) - .collect() - } - - /// Extract thinking content from tags, returning (content_without_think, thinking_content) - /// This handles both complete and incomplete (streaming) think tags. - pub fn extract_thinking(&self, content: &str) -> (String, Option) { - let mut result = String::new(); - let mut thinking = String::new(); - let mut current_pos = 0; - - while let Some(start_pos) = content[current_pos..].find("") { - let abs_start = current_pos + start_pos; - - // Add content before tag to result - result.push_str(&content[current_pos..abs_start]); - - // Find closing tag - if let Some(end_pos) = content[abs_start..].find("") { - let abs_end = abs_start + end_pos; - let think_content = &content[abs_start + 7..abs_end]; // 7 = len("") - - if !thinking.is_empty() { - thinking.push_str("\n\n"); - } - thinking.push_str(think_content.trim()); - - current_pos = abs_end + 8; // 8 = len("") - } else { - // Unclosed tag - this is streaming content - // Extract everything after as thinking content - let think_content = &content[abs_start + 7..]; // 7 = len("") - - if !thinking.is_empty() { - thinking.push_str("\n\n"); - } - thinking.push_str(think_content); - - current_pos = content.len(); - break; - } - } - - // Add remaining content - result.push_str(&content[current_pos..]); - - let thinking_result = if thinking.is_empty() { - None - } else { - Some(thinking) - }; - - // If the result is empty but we have thinking content, show a placeholder - if result.trim().is_empty() && thinking_result.is_some() { - result.push_str("[Thinking...]"); - } - - (result, thinking_result) - } -} diff --git a/crates/owlen-core/src/github.rs b/crates/owlen-core/src/github.rs deleted file mode 100644 index 19abc27..0000000 --- a/crates/owlen-core/src/github.rs +++ /dev/null @@ -1,258 +0,0 @@ -use crate::automation::repo::{PullRequestContext, summarize_diff}; -use crate::{Error, Result}; -use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderValue, USER_AGENT}; -use serde::{Deserialize, Serialize}; - -const DEFAULT_API_ENDPOINT: &str = "https://api.github.com"; -const USER_AGENT_VALUE: &str = "owlen/0.2"; - -/// Lightweight GitHub API client used for repository automation workflows. -pub struct GithubClient { - client: reqwest::Client, - base_url: String, - token: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct GithubConfig { - pub token: Option, - pub api_endpoint: Option, -} - -impl GithubClient { - pub fn new(config: GithubConfig) -> Result { - let client = reqwest::Client::builder() - .user_agent(USER_AGENT_VALUE) - .build() - .map_err(|err| Error::Network(err.to_string()))?; - Ok(Self { - client, - base_url: config - .api_endpoint - .unwrap_or_else(|| DEFAULT_API_ENDPOINT.to_string()), - token: config.token, - }) - } - - /// Fetch a pull request, returning diff text along with contextual metadata. - pub async fn pull_request( - &self, - owner: &str, - repo: &str, - number: u64, - ) -> Result { - let pr = self.fetch_pull_request(owner, repo, number).await?; - let diff = self.fetch_diff(owner, repo, number).await?; - let files = self.fetch_files(owner, repo, number).await?; - let stats = summarize_diff(&diff); - let context = PullRequestContext { - title: pr - .title - .clone() - .unwrap_or_else(|| format!("PR #{}", pr.number)), - body: pr.body.clone(), - author: pr.user.map(|user| user.login), - base_branch: pr.base.ref_field, - head_branch: pr.head.ref_field, - additions: stats.additions as u64, - deletions: stats.deletions as u64, - changed_files: stats.files as u64, - html_url: pr.html_url, - }; - - Ok(PullRequestDetails { - context, - diff, - files, - }) - } - - async fn fetch_pull_request( - &self, - owner: &str, - repo: &str, - number: u64, - ) -> Result { - let url = format!( - "{}/repos/{}/{}/pulls/{}", - self.base_url.trim_end_matches('/'), - owner, - repo, - number - ); - let response = self - .request(&url, Some("application/vnd.github+json"))? - .send() - .await - .map_err(|err| Error::Network(err.to_string()))?; - if !response.status().is_success() { - return Err(Error::Network(format!( - "GitHub returned status {} while fetching pull request", - response.status() - ))); - } - response - .json::() - .await - .map_err(|err| Error::Network(err.to_string())) - } - - async fn fetch_diff(&self, owner: &str, repo: &str, number: u64) -> Result { - let url = format!( - "{}/repos/{}/{}/pulls/{}", - self.base_url.trim_end_matches('/'), - owner, - repo, - number - ); - let response = self - .request(&url, Some("application/vnd.github.v3.diff"))? - .send() - .await - .map_err(|err| Error::Network(err.to_string()))?; - if !response.status().is_success() { - return Err(Error::Network(format!( - "GitHub returned status {} while downloading diff", - response.status() - ))); - } - response - .text() - .await - .map_err(|err| Error::Network(err.to_string())) - } - - async fn fetch_files( - &self, - owner: &str, - repo: &str, - number: u64, - ) -> Result> { - let mut results = Vec::new(); - let mut next_url = Some(format!( - "{}/repos/{}/{}/pulls/{}/files?per_page=100", - self.base_url.trim_end_matches('/'), - owner, - repo, - number - )); - - while let Some(url) = next_url { - let response = self - .request(&url, Some("application/vnd.github+json"))? - .send() - .await - .map_err(|err| Error::Network(err.to_string()))?; - if !response.status().is_success() { - return Err(Error::Network(format!( - "GitHub returned status {} while listing PR files", - response.status() - ))); - } - let link_header = response.headers().get("link").cloned(); - let page: Vec = response - .json() - .await - .map_err(|err| Error::Network(err.to_string()))?; - results.extend(page.into_iter().map(GithubPullFile::from)); - next_url = next_link(link_header.as_ref()); - } - - Ok(results) - } - - fn request(&self, url: &str, accept: Option<&str>) -> Result { - let mut builder = self.client.get(url); - builder = builder.header(USER_AGENT, USER_AGENT_VALUE); - if let Some(token) = &self.token { - builder = builder.header(AUTHORIZATION, format!("token {}", token)); - } - if let Some(accept) = accept { - builder = builder.header(ACCEPT, accept); - } - Ok(builder) - } -} - -/// Rich pull request details used by automation workflows. -#[derive(Debug, Clone)] -pub struct PullRequestDetails { - pub context: PullRequestContext, - pub diff: String, - pub files: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GithubPullFile { - pub filename: String, - pub status: String, - pub additions: u64, - pub deletions: u64, - pub changes: u64, - pub patch: Option, -} - -impl From for GithubPullFile { - fn from(value: GitHubPullFileApi) -> Self { - Self { - filename: value.filename, - status: value.status, - additions: value.additions, - deletions: value.deletions, - changes: value.changes, - patch: value.patch, - } - } -} - -#[derive(Debug, Deserialize)] -struct GitHubPullRequest { - number: u64, - title: Option, - body: Option, - user: Option, - base: GitRef, - head: GitRef, - html_url: Option, -} - -#[derive(Debug, Deserialize)] -struct GitHubUser { - login: String, -} - -#[derive(Debug, Deserialize)] -struct GitRef { - #[serde(rename = "ref")] - ref_field: String, -} - -#[derive(Debug, Deserialize)] -struct GitHubPullFileApi { - filename: String, - status: String, - additions: u64, - deletions: u64, - changes: u64, - #[serde(default)] - patch: Option, -} - -fn next_link(header: Option<&HeaderValue>) -> Option { - let header = header?.to_str().ok()?; - for part in header.split(',') { - let segments: Vec<&str> = part.split(';').collect(); - if segments.len() < 2 { - continue; - } - let url = segments[0] - .trim() - .trim_start_matches('<') - .trim_end_matches('>'); - let rel = segments[1].trim(); - if rel == "rel=\"next\"" { - return Some(url.to_string()); - } - } - None -} diff --git a/crates/owlen-core/src/input.rs b/crates/owlen-core/src/input.rs deleted file mode 100644 index ebe2e3f..0000000 --- a/crates/owlen-core/src/input.rs +++ /dev/null @@ -1,223 +0,0 @@ -use std::collections::VecDeque; - -/// Text input buffer with history and cursor management. -#[derive(Debug, Clone)] -pub struct InputBuffer { - buffer: String, - cursor: usize, - history: VecDeque, - history_index: Option, - max_history: usize, - pub multiline: bool, - tab_width: u8, -} - -impl InputBuffer { - /// Create a new input buffer - pub fn new(max_history: usize, multiline: bool, tab_width: u8) -> Self { - Self { - buffer: String::new(), - cursor: 0, - history: VecDeque::with_capacity(max_history.max(1)), - history_index: None, - max_history: max_history.max(1), - multiline, - tab_width: tab_width.max(1), - } - } - - /// Get current text - pub fn text(&self) -> &str { - &self.buffer - } - - /// Current cursor position - pub fn cursor(&self) -> usize { - self.cursor - } - - /// Replace buffer contents - pub fn set_text(&mut self, text: impl Into) { - self.buffer = text.into(); - self.cursor = self.buffer.len(); - self.history_index = None; - } - - /// Clear buffer and reset cursor - pub fn clear(&mut self) { - self.buffer.clear(); - self.cursor = 0; - self.history_index = None; - } - - /// Insert a character at the cursor position - pub fn insert_char(&mut self, ch: char) { - if ch == '\t' { - self.insert_tab(); - return; - } - - self.buffer.insert(self.cursor, ch); - self.cursor += ch.len_utf8(); - } - - /// Insert text at cursor - pub fn insert_text(&mut self, text: &str) { - self.buffer.insert_str(self.cursor, text); - self.cursor += text.len(); - } - - /// Insert spaces representing a tab - pub fn insert_tab(&mut self) { - let spaces = " ".repeat(self.tab_width as usize); - self.insert_text(&spaces); - } - - /// Remove character before cursor - pub fn backspace(&mut self) { - if self.cursor == 0 { - return; - } - - let prev_index = prev_char_boundary(&self.buffer, self.cursor); - self.buffer.drain(prev_index..self.cursor); - self.cursor = prev_index; - } - - /// Remove character at cursor - pub fn delete(&mut self) { - if self.cursor >= self.buffer.len() { - return; - } - - let next_index = next_char_boundary(&self.buffer, self.cursor); - self.buffer.drain(self.cursor..next_index); - } - - /// Move cursor left by one grapheme - pub fn move_left(&mut self) { - if self.cursor == 0 { - return; - } - self.cursor = prev_char_boundary(&self.buffer, self.cursor); - } - - /// Move cursor right by one grapheme - pub fn move_right(&mut self) { - if self.cursor >= self.buffer.len() { - return; - } - self.cursor = next_char_boundary(&self.buffer, self.cursor); - } - - /// Move cursor to start of the buffer - pub fn move_home(&mut self) { - self.cursor = 0; - } - - /// Move cursor to end of the buffer - pub fn move_end(&mut self) { - self.cursor = self.buffer.len(); - } - - /// Push current buffer into history, clearing the buffer afterwards - pub fn commit_to_history(&mut self) -> String { - let text = std::mem::take(&mut self.buffer); - if !text.trim().is_empty() { - self.push_history_entry(text.clone()); - } - self.cursor = 0; - self.history_index = None; - text - } - - /// Navigate to previous history entry - pub fn history_previous(&mut self) { - if self.history.is_empty() { - return; - } - - let new_index = match self.history_index { - Some(idx) if idx + 1 < self.history.len() => idx + 1, - None => 0, - _ => return, - }; - - self.history_index = Some(new_index); - if let Some(entry) = self.history.get(new_index) { - self.buffer = entry.clone(); - self.cursor = self.buffer.len(); - } - } - - /// Navigate to next history entry - pub fn history_next(&mut self) { - if self.history.is_empty() { - return; - } - - if let Some(idx) = self.history_index { - if idx > 0 { - let new_idx = idx - 1; - self.history_index = Some(new_idx); - if let Some(entry) = self.history.get(new_idx) { - self.buffer = entry.clone(); - self.cursor = self.buffer.len(); - } - } else { - self.history_index = None; - self.buffer.clear(); - self.cursor = 0; - } - } else { - self.buffer.clear(); - self.cursor = 0; - } - } - - /// Push a new entry into the history buffer, enforcing capacity - pub fn push_history_entry(&mut self, entry: String) { - if self - .history - .front() - .map(|existing| existing == &entry) - .unwrap_or(false) - { - return; - } - - self.history.push_front(entry); - while self.history.len() > self.max_history { - self.history.pop_back(); - } - } - - /// Clear saved input history entries. - pub fn clear_history(&mut self) { - self.history.clear(); - self.history_index = None; - } -} - -fn prev_char_boundary(buffer: &str, cursor: usize) -> usize { - buffer[..cursor] - .char_indices() - .last() - .map(|(idx, _)| idx) - .unwrap_or(0) -} - -fn next_char_boundary(buffer: &str, cursor: usize) -> usize { - if cursor >= buffer.len() { - return buffer.len(); - } - - let slice = &buffer[cursor..]; - let mut iter = slice.char_indices(); - iter.next(); - if let Some((idx, _)) = iter.next() { - cursor + idx - } else { - buffer.len() - } -} diff --git a/crates/owlen-core/src/lib.rs b/crates/owlen-core/src/lib.rs deleted file mode 100644 index f5e93cc..0000000 --- a/crates/owlen-core/src/lib.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Core traits and types for OWLEN LLM client -//! -//! This crate provides the foundational abstractions for building -//! LLM providers, routers, and MCP (Model Context Protocol) adapters. - -pub mod agent; -pub mod agent_registry; -pub mod automation; -pub mod config; -pub mod consent; -pub mod conversation; -pub mod credentials; -pub mod encryption; -pub mod facade; -pub mod formatting; -pub mod github; -pub mod input; -pub mod llm; -pub mod mcp; -pub mod mode; -pub mod model; -pub mod oauth; -pub mod provider; -pub mod providers; -pub mod router; -pub mod sandbox; -pub mod session; -pub mod state; -pub mod storage; -pub mod tools; -pub mod types; -pub mod ui; -pub mod usage; -pub mod validation; -pub mod wrap_cursor; - -// Re-export theme types from owlen-ui-common -pub use owlen_ui_common::{ - Color, NamedColor, Theme, ThemePalette, built_in_themes, default_themes_dir, get_theme, - load_all_themes, -}; - -pub use agent::*; -pub use agent_registry::*; -pub use automation::*; -pub use config::*; -pub use consent::*; -pub use conversation::*; -pub use credentials::*; -pub use encryption::*; -pub use formatting::*; -pub use github::*; -pub use input::*; -pub use oauth::*; -// Export MCP types but exclude test_utils to avoid ambiguity -pub use facade::llm_client::*; -pub use llm::{ - ChatStream, LlmProvider, Provider, ProviderConfig, ProviderRegistry, send_via_stream, -}; -pub use mcp::{ - LocalMcpClient, McpServer, McpToolCall, McpToolDescriptor, McpToolResponse, client, factory, - failover, permission, protocol, remote_client, -}; -pub use mode::*; -pub use model::*; -pub use provider::*; -pub use providers::*; -pub use router::*; -pub use sandbox::*; -pub use session::*; -pub use state::*; -pub use tools::*; -pub use usage::*; -pub use validation::*; - -/// Result type used throughout the OWLEN ecosystem -pub type Result = std::result::Result; - -/// Core error types for OWLEN -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("Provider error: {0}")] - Provider(#[from] anyhow::Error), - - #[error("Provider failure: {0}")] - ProviderFailure(provider::ProviderError), - - #[error("Network error: {0}")] - Network(String), - - #[error("Authentication error: {0}")] - Auth(String), - - #[error("Configuration error: {0}")] - Config(String), - - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - - #[error("Invalid input: {0}")] - InvalidInput(String), - - #[error("Operation timed out: {0}")] - Timeout(String), - - #[error("Serialization error: {0}")] - Serialization(#[from] serde_json::Error), - - #[error("Storage error: {0}")] - Storage(String), - - #[error("Unknown error: {0}")] - Unknown(String), - - #[error("Not implemented: {0}")] - NotImplemented(String), - - #[error("Permission denied: {0}")] - PermissionDenied(String), - - #[error("Agent execution error: {0}")] - Agent(String), -} diff --git a/crates/owlen-core/src/llm/mod.rs b/crates/owlen-core/src/llm/mod.rs deleted file mode 100644 index 6c8aa5f..0000000 --- a/crates/owlen-core/src/llm/mod.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! LLM provider abstractions and registry. -//! -//! This module defines the provider trait hierarchy along with helpers that -//! make it easy to register concrete LLM backends and access them through -//! dynamic dispatch when wiring the application together. - -use crate::{Error, Result, types::*}; -use anyhow::anyhow; -use futures::{Stream, StreamExt}; -use serde_json::Value; -use std::any::Any; -use std::collections::HashMap; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -/// A boxed stream of chat responses produced by a provider. -pub type ChatStream = Pin> + Send>>; - -/// Trait implemented by every LLM backend Owlen can speak to. -/// -/// Providers expose both one-shot and streaming prompt APIs. Concrete -/// implementations typically live in `crate::providers`. -pub trait LlmProvider: Send + Sync + 'static + Any + Sized { - /// Stream type returned by [`Self::stream_prompt`]. - type Stream: Stream> + Send + 'static; - - type ListModelsFuture<'a>: Future>> + Send - where - Self: 'a; - - type SendPromptFuture<'a>: Future> + Send - where - Self: 'a; - - type StreamPromptFuture<'a>: Future> + Send - where - Self: 'a; - - type HealthCheckFuture<'a>: Future> + Send - where - Self: 'a; - - /// Human-readable provider identifier. - fn name(&self) -> &str; - - /// Return metadata on all models exposed by this provider. - fn list_models(&self) -> Self::ListModelsFuture<'_>; - - /// Issue a prompt and wait for the provider to return the full response. - fn send_prompt(&self, request: ChatRequest) -> Self::SendPromptFuture<'_>; - - /// Issue a prompt and receive responses incrementally as a stream. - fn stream_prompt(&self, request: ChatRequest) -> Self::StreamPromptFuture<'_>; - - /// Perform a lightweight health check. - fn health_check(&self) -> Self::HealthCheckFuture<'_>; - - /// Provider-specific configuration schema (optional). - fn config_schema(&self) -> serde_json::Value { - serde_json::json!({}) - } - - /// Access the provider as an `Any` for downcasting. - fn as_any(&self) -> &(dyn Any + Send + Sync) { - self - } -} - -/// Helper that requests a streamed generation and yields the first chunk as a -/// regular response. This is handy for providers that only implement the -/// streaming API. -pub async fn send_via_stream<'a, P>(provider: &'a P, request: ChatRequest) -> Result -where - P: LlmProvider + 'a, -{ - let stream = provider.stream_prompt(request).await?; - let mut boxed: ChatStream = Box::pin(stream); - match boxed.next().await { - Some(Ok(response)) => Ok(response), - Some(Err(err)) => Err(err), - None => Err(Error::Provider(anyhow!( - "Empty chat stream from provider {}", - provider.name() - ))), - } -} - -/// Object-safe wrapper around [`LlmProvider`] for dynamic dispatch scenarios. -#[async_trait::async_trait] -pub trait Provider: Send + Sync { - fn name(&self) -> &str; - - async fn list_models(&self) -> Result>; - - async fn send_prompt(&self, request: ChatRequest) -> Result; - - async fn stream_prompt(&self, request: ChatRequest) -> Result; - - async fn health_check(&self) -> Result<()>; - - fn config_schema(&self) -> serde_json::Value { - serde_json::json!({}) - } - - fn as_any(&self) -> &(dyn Any + Send + Sync); -} - -#[async_trait::async_trait] -impl Provider for T -where - T: LlmProvider, -{ - fn name(&self) -> &str { - LlmProvider::name(self) - } - - async fn list_models(&self) -> Result> { - LlmProvider::list_models(self).await - } - - async fn send_prompt(&self, request: ChatRequest) -> Result { - LlmProvider::send_prompt(self, request).await - } - - async fn stream_prompt(&self, request: ChatRequest) -> Result { - let stream = LlmProvider::stream_prompt(self, request).await?; - Ok(Box::pin(stream)) - } - - async fn health_check(&self) -> Result<()> { - LlmProvider::health_check(self).await - } - - fn config_schema(&self) -> serde_json::Value { - LlmProvider::config_schema(self) - } - - fn as_any(&self) -> &(dyn Any + Send + Sync) { - LlmProvider::as_any(self) - } -} - -/// Runtime configuration for a provider instance. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ProviderConfig { - /// Whether this provider should be activated. - #[serde(default = "ProviderConfig::default_enabled")] - pub enabled: bool, - /// Provider type identifier used to resolve implementations. - #[serde(default)] - pub provider_type: String, - /// Base URL for API calls. - #[serde(default)] - pub base_url: Option, - /// API key or token material. - #[serde(default)] - pub api_key: Option, - /// Environment variable holding the API key. - #[serde(default)] - pub api_key_env: Option, - /// Additional provider-specific configuration. - #[serde(flatten)] - pub extra: HashMap, -} - -impl ProviderConfig { - const fn default_enabled() -> bool { - true - } - - /// Merge the current configuration with overrides from `other`. - pub fn merge_from(&mut self, mut other: ProviderConfig) { - self.enabled = other.enabled; - - if !other.provider_type.is_empty() { - self.provider_type = other.provider_type; - } - - if let Some(base_url) = other.base_url.take() { - self.base_url = Some(base_url); - } - - if let Some(api_key) = other.api_key.take() { - self.api_key = Some(api_key); - } - - if let Some(api_key_env) = other.api_key_env.take() { - self.api_key_env = Some(api_key_env); - } - - if !other.extra.is_empty() { - self.extra.extend(other.extra); - } - } -} - -/// Static registry of providers available to the application. -pub struct ProviderRegistry { - providers: HashMap>, -} - -impl ProviderRegistry { - pub fn new() -> Self { - Self { - providers: HashMap::new(), - } - } - - pub fn register(&mut self, provider: P) { - self.register_arc(Arc::new(provider)); - } - - pub fn register_arc(&mut self, provider: Arc) { - let name = provider.name().to_string(); - self.providers.insert(name, provider); - } - - pub fn get(&self, name: &str) -> Option> { - self.providers.get(name).cloned() - } - - pub fn list_providers(&self) -> Vec { - self.providers.keys().cloned().collect() - } - - pub async fn list_all_models(&self) -> Result> { - let mut all_models = Vec::new(); - - for provider in self.providers.values() { - match provider.list_models().await { - Ok(mut models) => all_models.append(&mut models), - Err(_) => { - // Ignore failing providers and continue. - } - } - } - - Ok(all_models) - } -} - -impl Default for ProviderRegistry { - fn default() -> Self { - Self::new() - } -} - -/// Test utilities for constructing mock providers. -#[cfg(test)] -pub mod test_utils { - use super::*; - use futures::stream; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// Simple provider stub that always returns the same response. - pub struct MockProvider { - name: String, - response: ChatResponse, - call_count: AtomicUsize, - } - - impl MockProvider { - pub fn new(name: impl Into, response: ChatResponse) -> Self { - Self { - name: name.into(), - response, - call_count: AtomicUsize::new(0), - } - } - - pub fn call_count(&self) -> usize { - self.call_count.load(Ordering::Relaxed) - } - } - - impl Default for MockProvider { - fn default() -> Self { - Self::new( - "mock-provider", - ChatResponse { - message: Message::assistant("mock response".to_string()), - usage: None, - is_streaming: false, - is_final: true, - }, - ) - } - } - - impl LlmProvider for MockProvider { - type Stream = stream::Iter>>; - - type ListModelsFuture<'a> - = futures::future::Ready>> - where - Self: 'a; - - type SendPromptFuture<'a> - = futures::future::Ready> - where - Self: 'a; - - type StreamPromptFuture<'a> - = futures::future::Ready> - where - Self: 'a; - - type HealthCheckFuture<'a> - = futures::future::Ready> - where - Self: 'a; - - fn name(&self) -> &str { - &self.name - } - - fn list_models(&self) -> Self::ListModelsFuture<'_> { - futures::future::ready(Ok(vec![])) - } - - fn send_prompt(&self, _request: ChatRequest) -> Self::SendPromptFuture<'_> { - self.call_count.fetch_add(1, Ordering::Relaxed); - futures::future::ready(Ok(self.response.clone())) - } - - fn stream_prompt(&self, _request: ChatRequest) -> Self::StreamPromptFuture<'_> { - self.call_count.fetch_add(1, Ordering::Relaxed); - let response = self.response.clone(); - futures::future::ready(Ok(stream::iter(vec![Ok(response)]))) - } - - fn health_check(&self) -> Self::HealthCheckFuture<'_> { - futures::future::ready(Ok(())) - } - } -} diff --git a/crates/owlen-core/src/mcp.rs b/crates/owlen-core/src/mcp.rs deleted file mode 100644 index ac4b9af..0000000 --- a/crates/owlen-core/src/mcp.rs +++ /dev/null @@ -1,188 +0,0 @@ -use crate::Result; -use crate::mode::Mode; -use crate::tools::registry::ToolRegistry; -use crate::validation::SchemaValidator; -use async_trait::async_trait; -pub use client::McpClient; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -pub mod client; -pub mod factory; -pub mod failover; -pub mod permission; -pub mod presets; -pub mod protocol; -pub mod remote_client; - -/// Descriptor for a tool exposed over MCP -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpToolDescriptor { - pub name: String, - pub description: String, - pub input_schema: Value, - pub requires_network: bool, - pub requires_filesystem: Vec, -} - -/// Invocation payload for a tool call -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpToolCall { - pub name: String, - pub arguments: Value, -} - -/// Result returned by a tool invocation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpToolResponse { - pub name: String, - pub success: bool, - pub output: Value, - pub metadata: HashMap, - pub duration_ms: u128, -} - -/// Thin MCP server facade over the tool registry -pub struct McpServer { - registry: Arc, - validator: Arc, - mode: Arc>, -} - -impl McpServer { - pub fn new(registry: Arc, validator: Arc) -> Self { - Self { - registry, - validator, - mode: Arc::new(tokio::sync::RwLock::new(Mode::default())), - } - } - - /// Set the current operating mode - pub async fn set_mode(&self, mode: Mode) { - *self.mode.write().await = mode; - } - - /// Get the current operating mode - pub async fn get_mode(&self) -> Mode { - *self.mode.read().await - } - - /// Enumerate the registered tools as MCP descriptors - pub async fn list_tools(&self) -> Vec { - let mode = self.get_mode().await; - let available_tools = self.registry.available_tools(mode).await; - - self.registry - .all() - .into_iter() - .filter(|tool| available_tools.contains(&tool.name().to_string())) - .map(|tool| McpToolDescriptor { - name: tool.name().to_string(), - description: tool.description().to_string(), - input_schema: tool.schema(), - requires_network: tool.requires_network(), - requires_filesystem: tool.requires_filesystem(), - }) - .collect() - } - - /// Execute a tool call after validating inputs against the registered schema - pub async fn call_tool(&self, call: McpToolCall) -> Result { - self.validator.validate(&call.name, &call.arguments)?; - let mode = self.get_mode().await; - let result = self - .registry - .execute(&call.name, call.arguments, mode) - .await?; - Ok(McpToolResponse { - name: call.name, - success: result.success, - output: result.output, - metadata: result.metadata, - duration_ms: duration_to_millis(result.duration), - }) - } -} - -fn duration_to_millis(duration: Duration) -> u128 { - duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis()) -} - -pub struct LocalMcpClient { - server: McpServer, -} - -impl LocalMcpClient { - pub fn new(registry: Arc, validator: Arc) -> Self { - Self { - server: McpServer::new(registry, validator), - } - } - - /// Set the current operating mode - pub async fn set_mode(&self, mode: Mode) { - self.server.set_mode(mode).await; - } - - /// Get the current operating mode - pub async fn get_mode(&self) -> Mode { - self.server.get_mode().await - } -} - -#[async_trait] -impl McpClient for LocalMcpClient { - async fn list_tools(&self) -> Result> { - Ok(self.server.list_tools().await) - } - - async fn call_tool(&self, call: McpToolCall) -> Result { - self.server.call_tool(call).await - } - - async fn set_mode(&self, mode: Mode) -> Result<()> { - self.server.set_mode(mode).await; - Ok(()) - } -} - -#[cfg(test)] -pub mod test_utils { - use super::*; - - /// Mock MCP client for testing - #[derive(Default)] - pub struct MockMcpClient; - - #[async_trait] - impl McpClient for MockMcpClient { - async fn list_tools(&self) -> Result> { - Ok(vec![McpToolDescriptor { - name: "mock_tool".to_string(), - description: "A mock tool for testing".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": { - "query": {"type": "string"} - } - }), - requires_network: false, - requires_filesystem: vec![], - }]) - } - - async fn call_tool(&self, call: McpToolCall) -> Result { - Ok(McpToolResponse { - name: call.name, - success: true, - output: serde_json::json!({"result": "mock result"}), - metadata: HashMap::new(), - duration_ms: 10, - }) - } - } -} diff --git a/crates/owlen-core/src/mcp/client.rs b/crates/owlen-core/src/mcp/client.rs deleted file mode 100644 index 85a91b0..0000000 --- a/crates/owlen-core/src/mcp/client.rs +++ /dev/null @@ -1,21 +0,0 @@ -use super::{McpToolCall, McpToolDescriptor, McpToolResponse}; -use crate::{Result, mode::Mode}; -use async_trait::async_trait; - -/// Trait for a client that can interact with an MCP server -#[async_trait] -pub trait McpClient: Send + Sync { - /// List the tools available on the server - async fn list_tools(&self) -> Result>; - - /// Call a tool on the server - async fn call_tool(&self, call: McpToolCall) -> Result; - - /// Update the server with the active operating mode. - async fn set_mode(&self, _mode: Mode) -> Result<()> { - Ok(()) - } -} - -// Re-export the concrete implementation that supports stdio and HTTP transports. -pub use super::remote_client::RemoteMcpClient; diff --git a/crates/owlen-core/src/mcp/factory.rs b/crates/owlen-core/src/mcp/factory.rs deleted file mode 100644 index 3e0ed1c..0000000 --- a/crates/owlen-core/src/mcp/factory.rs +++ /dev/null @@ -1,194 +0,0 @@ -/// MCP Client Factory -/// -/// Provides a unified interface for creating MCP clients based on configuration. -/// Supports switching between local (in-process) and remote (STDIO) execution modes. -use super::client::McpClient; -use super::{ - LocalMcpClient, - remote_client::{McpRuntimeSecrets, RemoteMcpClient}, -}; -use crate::config::{Config, McpMode}; -use crate::tools::registry::ToolRegistry; -use crate::validation::SchemaValidator; -use crate::{Error, Result}; -use log::{info, warn}; -use std::sync::Arc; - -/// Factory for creating MCP clients based on configuration -pub struct McpClientFactory { - config: Arc, - registry: Arc, - validator: Arc, -} - -impl McpClientFactory { - pub fn new( - config: Arc, - registry: Arc, - validator: Arc, - ) -> Self { - Self { - config, - registry, - validator, - } - } - - /// Create an MCP client based on the current configuration. - pub async fn create(&self) -> Result> { - self.create_with_secrets(None).await - } - - /// Create an MCP client using optional runtime secrets (OAuth tokens, env overrides). - pub async fn create_with_secrets( - &self, - runtime: Option, - ) -> Result> { - match self.config.mcp.mode { - McpMode::Disabled => Err(Error::Config( - "MCP mode is set to 'disabled'; tooling cannot function in this configuration." - .to_string(), - )), - McpMode::LocalOnly | McpMode::Legacy => { - if matches!(self.config.mcp.mode, McpMode::Legacy) { - warn!("Using deprecated MCP legacy mode; consider switching to 'local_only'."); - } - Ok(Box::new(LocalMcpClient::new( - self.registry.clone(), - self.validator.clone(), - ))) - } - McpMode::RemoteOnly => { - let server_cfg = self.config.effective_mcp_servers().first().ok_or_else(|| { - Error::Config( - "MCP mode 'remote_only' requires at least one entry in [[mcp_servers]]" - .to_string(), - ) - })?; - - RemoteMcpClient::new_with_runtime(server_cfg, runtime) - .await - .map(|client| Box::new(client) as Box) - .map_err(|e| { - Error::Config(format!( - "Failed to start remote MCP client '{}': {e}", - server_cfg.name - )) - }) - } - McpMode::RemotePreferred => { - if let Some(server_cfg) = self.config.effective_mcp_servers().first() { - match RemoteMcpClient::new_with_runtime(server_cfg, runtime.clone()).await { - Ok(client) => { - info!( - "Connected to remote MCP server '{}' via {} transport.", - server_cfg.name, server_cfg.transport - ); - Ok(Box::new(client) as Box) - } - Err(e) if self.config.mcp.allow_fallback => { - warn!( - "Failed to start remote MCP client '{}': {}. Falling back to local tooling.", - server_cfg.name, e - ); - Ok(Box::new(LocalMcpClient::new( - self.registry.clone(), - self.validator.clone(), - ))) - } - Err(e) => Err(Error::Config(format!( - "Failed to start remote MCP client '{}': {e}. To allow fallback, set [mcp].allow_fallback = true.", - server_cfg.name - ))), - } - } else { - warn!("No MCP servers configured; using local MCP tooling."); - Ok(Box::new(LocalMcpClient::new( - self.registry.clone(), - self.validator.clone(), - ))) - } - } - } - } - - /// Check if remote MCP mode is available - pub async fn is_remote_available() -> bool { - RemoteMcpClient::new().await.is_ok() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Error; - use crate::config::McpServerConfig; - - fn build_factory(config: Config) -> McpClientFactory { - let ui = Arc::new(crate::ui::NoOpUiController); - let registry = Arc::new(ToolRegistry::new( - Arc::new(tokio::sync::Mutex::new(config.clone())), - ui, - )); - let validator = Arc::new(SchemaValidator::new()); - - McpClientFactory::new(Arc::new(config), registry, validator) - } - - #[tokio::test] - async fn test_factory_creates_local_client_when_no_servers_configured() { - let mut config = Config::default(); - config.refresh_mcp_servers(None).unwrap(); - - let factory = build_factory(config); - - // Should create without error and fall back to local client - let result = factory.create().await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_remote_only_without_servers_errors() { - let mut config = Config::default(); - config.mcp.mode = McpMode::RemoteOnly; - config.mcp_servers.clear(); - config.refresh_mcp_servers(None).unwrap(); - - let factory = build_factory(config); - let result = factory.create().await; - assert!(matches!(result, Err(Error::Config(_)))); - } - - #[tokio::test] - async fn test_remote_preferred_without_fallback_propagates_remote_error() { - let mut config = Config::default(); - config.mcp.mode = McpMode::RemotePreferred; - config.mcp.allow_fallback = false; - config.mcp_servers = vec![McpServerConfig { - name: "invalid".to_string(), - command: "nonexistent-mcp-server-binary".to_string(), - args: Vec::new(), - transport: "stdio".to_string(), - env: std::collections::HashMap::new(), - oauth: None, - rpc_timeout_secs: None, - }]; - config.refresh_mcp_servers(None).unwrap(); - - let factory = build_factory(config); - let result = factory.create().await; - assert!( - matches!(result, Err(Error::Config(message)) if message.contains("Failed to start remote MCP client")) - ); - } - - #[tokio::test] - async fn test_legacy_mode_uses_local_client() { - let mut config = Config::default(); - config.mcp.mode = McpMode::Legacy; - - let factory = build_factory(config); - let result = factory.create().await; - assert!(result.is_ok()); - } -} diff --git a/crates/owlen-core/src/mcp/failover.rs b/crates/owlen-core/src/mcp/failover.rs deleted file mode 100644 index ba19668..0000000 --- a/crates/owlen-core/src/mcp/failover.rs +++ /dev/null @@ -1,324 +0,0 @@ -//! Failover and redundancy support for MCP clients -//! -//! Provides automatic failover between multiple MCP servers with: -//! - Health checking -//! - Priority-based selection -//! - Automatic retry with exponential backoff -//! - Circuit breaker pattern - -use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse}; -use crate::{Error, Result}; -use async_trait::async_trait; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -/// Server health status -#[derive(Debug, Clone, PartialEq)] -pub enum ServerHealth { - /// Server is healthy and available - Healthy, - /// Server is experiencing issues but may recover - Degraded { since: Instant }, - /// Server is down - Down { since: Instant }, -} - -/// Server configuration with priority -#[derive(Clone)] -pub struct ServerEntry { - /// Name for logging - pub name: String, - /// MCP client instance - pub client: Arc, - /// Priority (lower = higher priority) - pub priority: u32, - /// Health status - health: Arc>, - /// Last health check time - last_check: Arc>>, -} - -impl ServerEntry { - pub fn new(name: String, client: Arc, priority: u32) -> Self { - Self { - name, - client, - priority, - health: Arc::new(RwLock::new(ServerHealth::Healthy)), - last_check: Arc::new(RwLock::new(None)), - } - } - - /// Check if server is available - pub async fn is_available(&self) -> bool { - let health = self.health.read().await; - matches!(*health, ServerHealth::Healthy) - } - - /// Mark server as healthy - pub async fn mark_healthy(&self) { - let mut health = self.health.write().await; - *health = ServerHealth::Healthy; - let mut last_check = self.last_check.write().await; - *last_check = Some(Instant::now()); - } - - /// Mark server as down - pub async fn mark_down(&self) { - let mut health = self.health.write().await; - *health = ServerHealth::Down { - since: Instant::now(), - }; - } - - /// Mark server as degraded - pub async fn mark_degraded(&self) { - let mut health = self.health.write().await; - if matches!(*health, ServerHealth::Healthy) { - *health = ServerHealth::Degraded { - since: Instant::now(), - }; - } - } - - /// Get current health status - pub async fn get_health(&self) -> ServerHealth { - self.health.read().await.clone() - } -} - -/// Failover configuration -#[derive(Debug, Clone)] -pub struct FailoverConfig { - /// Maximum number of retry attempts - pub max_retries: usize, - /// Base retry delay (will be exponentially increased) - pub base_retry_delay: Duration, - /// Health check interval - pub health_check_interval: Duration, - /// Timeout for health checks - pub health_check_timeout: Duration, - /// Circuit breaker threshold (failures before opening circuit) - pub circuit_breaker_threshold: usize, -} - -impl Default for FailoverConfig { - fn default() -> Self { - Self { - max_retries: 3, - base_retry_delay: Duration::from_millis(100), - health_check_interval: Duration::from_secs(30), - health_check_timeout: Duration::from_secs(5), - circuit_breaker_threshold: 5, - } - } -} - -/// MCP client with failover support -pub struct FailoverMcpClient { - servers: Arc>>, - config: FailoverConfig, - consecutive_failures: Arc>, -} - -impl FailoverMcpClient { - /// Create a new failover client with multiple servers - pub fn new(servers: Vec, config: FailoverConfig) -> Self { - // Sort servers by priority - let mut sorted_servers = servers; - sorted_servers.sort_by_key(|s| s.priority); - - Self { - servers: Arc::new(RwLock::new(sorted_servers)), - config, - consecutive_failures: Arc::new(RwLock::new(0)), - } - } - - /// Create with default configuration - pub fn with_servers(servers: Vec) -> Self { - Self::new(servers, FailoverConfig::default()) - } - - /// Get the first available server - async fn get_available_server(&self) -> Option { - let servers = self.servers.read().await; - for server in servers.iter() { - if server.is_available().await { - return Some(server.clone()); - } - } - None - } - - /// Execute an operation with automatic failover - async fn with_failover(&self, operation: F) -> Result - where - F: Fn(Arc) -> futures::future::BoxFuture<'static, Result>, - T: Send + 'static, - { - let mut attempt = 0; - let mut last_error = None; - - while attempt < self.config.max_retries { - // Get available server - let server = match self.get_available_server().await { - Some(s) => s, - None => { - // No healthy servers, try all servers anyway - let servers = self.servers.read().await; - if let Some(first) = servers.first() { - first.clone() - } else { - return Err(Error::Network("No servers configured".to_string())); - } - } - }; - - // Execute operation - match operation(server.client.clone()).await { - Ok(result) => { - server.mark_healthy().await; - let mut failures = self.consecutive_failures.write().await; - *failures = 0; - return Ok(result); - } - Err(e) => { - log::warn!("Server '{}' failed: {}", server.name, e); - server.mark_degraded().await; - last_error = Some(e); - - let mut failures = self.consecutive_failures.write().await; - *failures += 1; - - if *failures >= self.config.circuit_breaker_threshold { - server.mark_down().await; - } - } - } - - // Exponential backoff - if attempt < self.config.max_retries - 1 { - let delay = self.config.base_retry_delay * 2_u32.pow(attempt as u32); - tokio::time::sleep(delay).await; - } - - attempt += 1; - } - - Err(last_error.unwrap_or_else(|| Error::Network("All servers failed".to_string()))) - } - - /// Perform health check on all servers - pub async fn health_check_all(&self) { - let servers = self.servers.read().await; - for server in servers.iter() { - let client = server.client.clone(); - let server_clone = server.clone(); - - tokio::spawn(async move { - match tokio::time::timeout( - Duration::from_secs(5), - // Use a simple list_tools call as health check - async { client.list_tools().await }, - ) - .await - { - Ok(Ok(_)) => server_clone.mark_healthy().await, - Ok(Err(e)) => { - log::warn!("Health check failed for '{}': {}", server_clone.name, e); - server_clone.mark_down().await; - } - Err(_) => { - log::warn!("Health check timeout for '{}'", server_clone.name); - server_clone.mark_down().await; - } - } - }); - } - } - - /// Start background health checking - pub fn start_health_checks(&self) -> tokio::task::JoinHandle<()> { - let client = self.clone_ref(); - let interval = self.config.health_check_interval; - - tokio::spawn(async move { - let mut interval_timer = tokio::time::interval(interval); - loop { - interval_timer.tick().await; - client.health_check_all().await; - } - }) - } - - /// Clone the client (returns new handle to same underlying data) - fn clone_ref(&self) -> Self { - Self { - servers: self.servers.clone(), - config: self.config.clone(), - consecutive_failures: self.consecutive_failures.clone(), - } - } - - /// Get status of all servers - pub async fn get_server_status(&self) -> Vec<(String, ServerHealth)> { - let servers = self.servers.read().await; - let mut status = Vec::new(); - for server in servers.iter() { - status.push((server.name.clone(), server.get_health().await)); - } - status - } -} - -#[async_trait] -impl McpClient for FailoverMcpClient { - async fn list_tools(&self) -> Result> { - self.with_failover(|client| Box::pin(async move { client.list_tools().await })) - .await - } - - async fn call_tool(&self, call: McpToolCall) -> Result { - self.with_failover(|client| { - let call_clone = call.clone(); - Box::pin(async move { client.call_tool(call_clone).await }) - }) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_server_entry_health() { - use crate::mcp::remote_client::RemoteMcpClient; - - // This would need a mock client in practice - // Just demonstrating the API - let config = crate::config::McpServerConfig { - name: "test".to_string(), - command: "test".to_string(), - args: vec![], - transport: "http".to_string(), - env: std::collections::HashMap::new(), - oauth: None, - rpc_timeout_secs: None, - }; - - if let Ok(client) = RemoteMcpClient::new_with_config(&config).await { - let entry = ServerEntry::new("test".to_string(), Arc::new(client), 1); - - assert!(entry.is_available().await); - - entry.mark_down().await; - assert!(!entry.is_available().await); - - entry.mark_healthy().await; - assert!(entry.is_available().await); - } - } -} diff --git a/crates/owlen-core/src/mcp/permission.rs b/crates/owlen-core/src/mcp/permission.rs deleted file mode 100644 index 525b5c0..0000000 --- a/crates/owlen-core/src/mcp/permission.rs +++ /dev/null @@ -1,229 +0,0 @@ -/// Permission and Safety Layer for MCP -/// -/// This module provides runtime enforcement of security policies for tool execution. -/// It wraps MCP clients to filter/whitelist tool calls, log invocations, and prompt for consent. -use super::client::McpClient; -use super::{McpToolCall, McpToolDescriptor, McpToolResponse}; -use crate::tools::{WEB_SEARCH_TOOL_NAME, tool_name_matches}; -use crate::{Error, Result}; -use crate::{config::Config, mode::Mode}; -use async_trait::async_trait; -use std::collections::HashSet; -use std::sync::Arc; - -/// Callback for requesting user consent for dangerous operations -pub type ConsentCallback = Arc bool + Send + Sync>; - -/// Callback for logging tool invocations -pub type LogCallback = Arc) + Send + Sync>; - -/// Permission-enforcing wrapper around an MCP client -pub struct PermissionLayer { - inner: Box, - config: Arc, - consent_callback: Option, - log_callback: Option, - allowed_tools: HashSet, -} - -impl PermissionLayer { - /// Create a new permission layer wrapping the given client - pub fn new(inner: Box, config: Arc) -> Self { - let allowed_tools = config.security.allowed_tools.iter().cloned().collect(); - - Self { - inner, - config, - consent_callback: None, - log_callback: None, - allowed_tools, - } - } - - /// Set a callback for requesting user consent - pub fn with_consent_callback(mut self, callback: ConsentCallback) -> Self { - self.consent_callback = Some(callback); - self - } - - /// Set a callback for logging tool invocations - pub fn with_log_callback(mut self, callback: LogCallback) -> Self { - self.log_callback = Some(callback); - self - } - - /// Check if a tool requires dangerous filesystem operations - fn requires_dangerous_filesystem(&self, tool_name: &str) -> bool { - matches!( - tool_name, - "resources_write" | "resources_delete" | "file_write" | "file_delete" - ) - } - - /// Check if a tool is allowed by security policy - fn is_tool_allowed(&self, tool_descriptor: &McpToolDescriptor) -> bool { - // Check if tool requires filesystem access - for fs_perm in &tool_descriptor.requires_filesystem { - if !self.allowed_tools.contains(fs_perm) { - return false; - } - } - - // Check if tool requires network access - if tool_descriptor.requires_network - && !self - .allowed_tools - .iter() - .any(|tool| tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)) - { - return false; - } - - true - } - - /// Request user consent for a tool call - fn request_consent(&self, tool_name: &str, call: &McpToolCall) -> bool { - if let Some(ref callback) = self.consent_callback { - callback(tool_name, call) - } else { - // If no callback is set, deny dangerous operations by default - !self.requires_dangerous_filesystem(tool_name) - } - } - - /// Log a tool invocation - fn log_invocation( - &self, - tool_name: &str, - call: &McpToolCall, - result: &Result, - ) { - if let Some(ref callback) = self.log_callback { - callback(tool_name, call, result); - } else { - // Default logging to stderr - match result { - Ok(resp) => { - eprintln!( - "[MCP] Tool '{}' executed successfully ({}ms)", - tool_name, resp.duration_ms - ); - } - Err(e) => { - eprintln!("[MCP] Tool '{}' failed: {}", tool_name, e); - } - } - } - } -} - -#[async_trait] -impl McpClient for PermissionLayer { - async fn list_tools(&self) -> Result> { - let tools = self.inner.list_tools().await?; - // Filter tools based on security policy - Ok(tools - .into_iter() - .filter(|tool| self.is_tool_allowed(tool)) - .collect()) - } - - async fn call_tool(&self, call: McpToolCall) -> Result { - // Check if tool requires consent - if self.requires_dangerous_filesystem(&call.name) - && self.config.privacy.require_consent_per_session - && !self.request_consent(&call.name, &call) - { - let result = Err(Error::PermissionDenied(format!( - "User denied consent for tool '{}'", - call.name - ))); - self.log_invocation(&call.name, &call, &result); - return result; - } - - // Execute the tool call - let result = self.inner.call_tool(call.clone()).await; - - // Log the invocation - self.log_invocation(&call.name, &call, &result); - - result - } - - async fn set_mode(&self, mode: Mode) -> Result<()> { - self.inner.set_mode(mode).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::mcp::LocalMcpClient; - use crate::tools::WEB_SEARCH_TOOL_NAME; - use crate::tools::registry::ToolRegistry; - use crate::ui::NoOpUiController; - use crate::validation::SchemaValidator; - use std::sync::atomic::{AtomicBool, Ordering}; - - #[tokio::test] - async fn test_permission_layer_filters_dangerous_tools() { - let config = Arc::new(Config::default()); - let ui = Arc::new(NoOpUiController); - let registry = Arc::new(ToolRegistry::new( - Arc::new(tokio::sync::Mutex::new((*config).clone())), - ui, - )); - let validator = Arc::new(SchemaValidator::new()); - let client = Box::new(LocalMcpClient::new(registry, validator)); - - let mut config_mut = (*config).clone(); - // Disallow file operations - config_mut.security.allowed_tools = vec![WEB_SEARCH_TOOL_NAME.to_string()]; - - let permission_layer = PermissionLayer::new(client, Arc::new(config_mut)); - - let tools = permission_layer.list_tools().await.unwrap(); - - // Should not include file_write or file_delete tools - assert!(!tools.iter().any(|t| t.name.contains("write"))); - assert!(!tools.iter().any(|t| t.name.contains("delete"))); - } - - #[tokio::test] - async fn test_consent_callback_is_invoked() { - let config = Arc::new(Config::default()); - let ui = Arc::new(NoOpUiController); - let registry = Arc::new(ToolRegistry::new( - Arc::new(tokio::sync::Mutex::new((*config).clone())), - ui, - )); - let validator = Arc::new(SchemaValidator::new()); - let client = Box::new(LocalMcpClient::new(registry, validator)); - - let consent_called = Arc::new(AtomicBool::new(false)); - let consent_called_clone = consent_called.clone(); - - let consent_callback: ConsentCallback = Arc::new(move |_tool, _call| { - consent_called_clone.store(true, Ordering::SeqCst); - false // Deny - }); - - let mut config_mut = (*config).clone(); - config_mut.privacy.require_consent_per_session = true; - - let permission_layer = PermissionLayer::new(client, Arc::new(config_mut)) - .with_consent_callback(consent_callback); - - let call = McpToolCall { - name: "resources_write".to_string(), - arguments: serde_json::json!({"path": "test.txt", "content": "hello"}), - }; - - let result = permission_layer.call_tool(call).await; - - assert!(consent_called.load(Ordering::SeqCst)); - assert!(result.is_err()); - } -} diff --git a/crates/owlen-core/src/mcp/presets.rs b/crates/owlen-core/src/mcp/presets.rs deleted file mode 100644 index 0bd75b9..0000000 --- a/crates/owlen-core/src/mcp/presets.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! Reference MCP connector presets shared across leading client ecosystems. -//! -//! These definitions intentionally avoid vendor-specific naming while capturing -//! the union of commonly shipped servers: local tooling, automation, retrieval, -//! observability, and productivity integrations. - -use crate::config::McpServerConfig; -use crate::tools::tool_identifier_violation; -use anyhow::{Result, anyhow}; -use std::collections::{HashMap, HashSet}; -use std::str::FromStr; - -/// High-level preset tiers exposed to CLI/TUI. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum PresetTier { - Standard, - Extended, - Full, -} - -impl PresetTier { - pub fn as_str(self) -> &'static str { - match self { - PresetTier::Standard => "standard", - PresetTier::Extended => "extended", - PresetTier::Full => "full", - } - } - - pub fn all() -> &'static [PresetTier] { - &[PresetTier::Standard, PresetTier::Extended, PresetTier::Full] - } - - fn description(self) -> &'static str { - match self { - PresetTier::Standard => { - "Core local tooling (filesystem, terminal, git, browser, fetch, python, notebook)." - } - PresetTier::Extended => { - "Standard + retrieval/automation connectors (search, scraping, planning)." - } - PresetTier::Full => { - "Extended + SaaS integrations (observability, productivity, data stores)." - } - } - } -} - -impl FromStr for PresetTier { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - let normalized = s.trim().to_ascii_lowercase(); - match normalized.as_str() { - "standard" | "std" => Ok(PresetTier::Standard), - "extended" | "ext" => Ok(PresetTier::Extended), - "full" | "all" => Ok(PresetTier::Full), - other => Err(anyhow!(format!( - "Unknown preset tier '{other}'. Expected one of: standard, extended, full." - ))), - } - } -} - -/// Lightweight description of an MCP connector entry. -#[derive(Debug, Clone, Copy)] -pub struct PresetConnector { - pub name: &'static str, - pub command: &'static str, - pub args: &'static [&'static str], - pub env: &'static [(&'static str, &'static str)], - pub description: &'static str, - pub capabilities: &'static [&'static str], -} - -impl PresetConnector { - pub fn to_config(&self) -> McpServerConfig { - if let Some(reason) = tool_identifier_violation(self.name) { - panic!("Invalid preset connector '{}': {reason}", self.name); - } - - McpServerConfig { - name: self.name.to_string(), - command: self.command.to_string(), - args: self.args.iter().map(|arg| arg.to_string()).collect(), - transport: "stdio".to_string(), - env: self - .env - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect::>(), - oauth: None, - rpc_timeout_secs: None, - } - } -} - -const STANDARD_CONNECTORS: &[PresetConnector] = &[ - PresetConnector { - name: "filesystem", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-filesystem"], - env: &[], - description: "Mount local project directories for read/write operations.", - capabilities: &["filesystem", "local"], - }, - PresetConnector { - name: "terminal", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-shell"], - env: &[], - description: "Execute shell commands within a sandboxed environment.", - capabilities: &["shell", "local"], - }, - PresetConnector { - name: "git", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-git"], - env: &[], - description: "Interact with Git repositories for status, diffs, commits.", - capabilities: &["git", "local"], - }, - PresetConnector { - name: "browser", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-browser"], - env: &[], - description: "Perform scripted browser automation via headless Chromium.", - capabilities: &["browser", "automation"], - }, - PresetConnector { - name: "fetch", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-fetch"], - env: &[], - description: "Issue structured HTTP requests for REST/JSON APIs.", - capabilities: &["network"], - }, - PresetConnector { - name: "python", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-python"], - env: &[], - description: "Run Python snippets in an isolated interpreter.", - capabilities: &["compute", "python"], - }, - PresetConnector { - name: "notebook", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-notebook"], - env: &[], - description: "Evaluate notebook cells and manage Jupyter sessions.", - capabilities: &["compute", "notebook"], - }, - PresetConnector { - name: "sequential_thinking", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-sequential-thinking"], - env: &[], - description: "Structured reasoning helper with planning support.", - capabilities: &["planning"], - }, - PresetConnector { - name: "puppeteer", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-puppeteer"], - env: &[], - description: "Full-browser automation via Puppeteer.", - capabilities: &["browser", "automation"], - }, -]; - -const EXTENDED_CONNECTORS: &[PresetConnector] = &[ - PresetConnector { - name: "brave_search", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-brave-search"], - env: &[("BRAVE_API_KEY", "")], - description: "Search the web using Brave Search APIs.", - capabilities: &["search", "network"], - }, - PresetConnector { - name: "tavily", - command: "npx", - args: &["-y", "@tavily/mcp-server"], - env: &[("TAVILY_API_KEY", "")], - description: "General-purpose research with Tavily's search/reasoning API.", - capabilities: &["search", "network"], - }, - PresetConnector { - name: "perplexity", - command: "npx", - args: &["-y", "@perplexity-ai/mcp-server"], - env: &[("PPLX_API_KEY", "")], - description: "Ask questions against Perplexity's API.", - capabilities: &["qa", "network"], - }, - PresetConnector { - name: "firecrawl", - command: "npx", - args: &["-y", "@firecrawl/mcp-server"], - env: &[("FIRECRAWL_TOKEN", "")], - description: "Crawl and scrape webpages for summarisation.", - capabilities: &["scrape", "network"], - }, - PresetConnector { - name: "memory_bank", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-memory"], - env: &[], - description: "Persist structured memories for long-lived tasks.", - capabilities: &["memory"], - }, -]; - -const FULL_CONNECTORS: &[PresetConnector] = &[ - PresetConnector { - name: "sentry", - command: "npx", - args: &["-y", "@sentry/mcp-server"], - env: &[("SENTRY_AUTH_TOKEN", "")], - description: "Query issues and alerts from Sentry.", - capabilities: &["observability", "network"], - }, - PresetConnector { - name: "notion", - command: "npx", - args: &["-y", "@notionhq/mcp-server"], - env: &[("NOTION_API_KEY", "")], - description: "Access Notion databases and pages.", - capabilities: &["productivity", "network"], - }, - PresetConnector { - name: "slack", - command: "npx", - args: &["-y", "@slack/mcp-server"], - env: &[("SLACK_BOT_TOKEN", "")], - description: "Send messages and search channels in Slack.", - capabilities: &["communication", "network"], - }, - PresetConnector { - name: "stripe", - command: "npx", - args: &["-y", "@stripe/mcp-server"], - env: &[("STRIPE_API_KEY", "")], - description: "Inspect customers, invoices, and payment intents.", - capabilities: &["payments", "network"], - }, - PresetConnector { - name: "google_drive", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-google-drive"], - env: &[("GOOGLE_DRIVE_CREDENTIALS", "")], - description: "Browse and fetch Google Drive documents.", - capabilities: &["storage", "network"], - }, - PresetConnector { - name: "zapier", - command: "npx", - args: &["-y", "@zapier/mcp-server"], - env: &[("ZAPIER_NLA_API_KEY", "")], - description: "Trigger Zapier actions and workflows.", - capabilities: &["automation", "network"], - }, - PresetConnector { - name: "postgresql", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-postgresql"], - env: &[], - description: "Run SQL against a PostgreSQL database.", - capabilities: &["database"], - }, - PresetConnector { - name: "sqlite", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-sqlite"], - env: &[], - description: "Run SQL against local SQLite databases.", - capabilities: &["database"], - }, - PresetConnector { - name: "redis", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-redis"], - env: &[], - description: "Inspect Redis keys and run commands.", - capabilities: &["cache", "database"], - }, - PresetConnector { - name: "qdrant", - command: "npx", - args: &["-y", "@modelcontextprotocol/server-qdrant"], - env: &[], - description: "Interact with Qdrant vector collections.", - capabilities: &["vector", "database"], - }, -]; - -fn connectors_for_tier_internal(tier: PresetTier) -> Vec { - let mut result = Vec::new(); - result.extend_from_slice(STANDARD_CONNECTORS); - if matches!(tier, PresetTier::Extended | PresetTier::Full) { - result.extend_from_slice(EXTENDED_CONNECTORS); - } - if matches!(tier, PresetTier::Full) { - result.extend_from_slice(FULL_CONNECTORS); - } - result -} - -/// Return connectors for the given tier (including lower tiers). -pub fn connectors_for_tier(tier: PresetTier) -> Vec { - connectors_for_tier_internal(tier) -} - -/// Describe the preset tiers for help output. -pub fn tier_descriptions() -> Vec<(PresetTier, &'static str)> { - PresetTier::all() - .iter() - .map(|tier| (*tier, tier.description())) - .collect() -} - -/// Details about changes performed when applying a preset. -#[derive(Debug)] -pub struct PresetApplyReport { - pub tier: PresetTier, - pub added: Vec, - pub updated: Vec, - pub removed: Vec, -} - -impl PresetApplyReport { - fn new(tier: PresetTier) -> Self { - Self { - tier, - added: Vec::new(), - updated: Vec::new(), - removed: Vec::new(), - } - } -} - -/// Details discovered during audit. -#[derive(Debug)] -pub struct PresetAuditReport { - pub tier: PresetTier, - pub missing: Vec, - pub mismatched: Vec<(PresetConnector, McpServerConfig)>, - pub extra: Vec, -} - -impl PresetAuditReport { - fn new(tier: PresetTier) -> Self { - Self { - tier, - missing: Vec::new(), - mismatched: Vec::new(), - extra: Vec::new(), - } - } -} - -/// Apply the requested preset to the given configuration. -pub fn apply_preset( - config: &mut crate::config::Config, - tier: PresetTier, - prune: bool, -) -> Result { - let mut report = PresetApplyReport::new(tier); - - let connectors = connectors_for_tier_internal(tier); - let expected_names: HashSet<&str> = connectors.iter().map(|c| c.name).collect(); - - if prune { - config.mcp_servers.retain(|existing| { - if expected_names.contains(existing.name.as_str()) { - true - } else { - report.removed.push(existing.name.clone()); - false - } - }); - } - - for connector in connectors { - match config - .mcp_servers - .iter_mut() - .find(|srv| srv.name == connector.name) - { - Some(existing) => { - let candidate = connector.to_config(); - if existing.command != candidate.command - || existing.args != candidate.args - || existing.env != candidate.env - { - *existing = candidate; - report.updated.push(connector.name.to_string()); - } - } - None => { - config.mcp_servers.push(connector.to_config()); - report.added.push(connector.name.to_string()); - } - } - } - - config.refresh_mcp_servers(None)?; - Ok(report) -} - -/// Audit the configuration against a preset without mutating it. -pub fn audit_preset(config: &crate::config::Config, tier: PresetTier) -> PresetAuditReport { - let mut report = PresetAuditReport::new(tier); - - let connectors = connectors_for_tier_internal(tier); - let expected: HashMap<&str, &PresetConnector> = - connectors.iter().map(|c| (c.name, c)).collect(); - let mut seen = HashSet::new(); - - for server in &config.mcp_servers { - if let Some(expected_connector) = expected.get(server.name.as_str()) { - seen.insert(server.name.as_str()); - let expected_config = expected_connector.to_config(); - if expected_config.command != server.command - || expected_config.args != server.args - || expected_config.env != server.env - { - report - .mismatched - .push((**expected_connector, server.clone())); - } - } else { - report.extra.push(server.clone()); - } - } - - for connector in connectors { - if !seen.contains(connector.name) { - report.missing.push(connector); - } - } - - report -} diff --git a/crates/owlen-core/src/mcp/protocol.rs b/crates/owlen-core/src/mcp/protocol.rs deleted file mode 100644 index f3c44cc..0000000 --- a/crates/owlen-core/src/mcp/protocol.rs +++ /dev/null @@ -1,389 +0,0 @@ -/// MCP Protocol Definitions -/// -/// This module defines the JSON-RPC protocol contracts for the Model Context Protocol (MCP). -/// It includes request/response schemas, error codes, and versioning semantics. -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// MCP Protocol version - uses semantic versioning -pub const PROTOCOL_VERSION: &str = "1.0.0"; - -/// JSON-RPC version constant -pub const JSONRPC_VERSION: &str = "2.0"; - -// ============================================================================ -// Error Codes and Handling -// ============================================================================ - -/// Standard JSON-RPC error codes following the spec -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ErrorCode(pub i64); - -impl ErrorCode { - // Standard JSON-RPC 2.0 errors - pub const PARSE_ERROR: Self = Self(-32700); - pub const INVALID_REQUEST: Self = Self(-32600); - pub const METHOD_NOT_FOUND: Self = Self(-32601); - pub const INVALID_PARAMS: Self = Self(-32602); - pub const INTERNAL_ERROR: Self = Self(-32603); - - // MCP-specific errors (range -32000 to -32099) - pub const TOOL_NOT_FOUND: Self = Self(-32000); - pub const TOOL_EXECUTION_FAILED: Self = Self(-32001); - pub const PERMISSION_DENIED: Self = Self(-32002); - pub const RESOURCE_NOT_FOUND: Self = Self(-32003); - pub const TIMEOUT: Self = Self(-32004); - pub const VALIDATION_ERROR: Self = Self(-32005); - pub const PATH_TRAVERSAL: Self = Self(-32006); - pub const RATE_LIMIT_EXCEEDED: Self = Self(-32007); -} - -/// Structured error response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RpcError { - pub code: i64, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -impl RpcError { - pub fn new(code: ErrorCode, message: impl Into) -> Self { - Self { - code: code.0, - message: message.into(), - data: None, - } - } - - pub fn with_data(mut self, data: Value) -> Self { - self.data = Some(data); - self - } - - pub fn parse_error(message: impl Into) -> Self { - Self::new(ErrorCode::PARSE_ERROR, message) - } - - pub fn invalid_request(message: impl Into) -> Self { - Self::new(ErrorCode::INVALID_REQUEST, message) - } - - pub fn method_not_found(method: &str) -> Self { - Self::new( - ErrorCode::METHOD_NOT_FOUND, - format!("Method not found: {}", method), - ) - } - - pub fn invalid_params(message: impl Into) -> Self { - Self::new(ErrorCode::INVALID_PARAMS, message) - } - - pub fn internal_error(message: impl Into) -> Self { - Self::new(ErrorCode::INTERNAL_ERROR, message) - } - - pub fn tool_not_found(tool_name: &str) -> Self { - Self::new( - ErrorCode::TOOL_NOT_FOUND, - format!("Tool not found: {}", tool_name), - ) - } - - pub fn permission_denied(message: impl Into) -> Self { - Self::new(ErrorCode::PERMISSION_DENIED, message) - } - - pub fn path_traversal() -> Self { - Self::new(ErrorCode::PATH_TRAVERSAL, "Path traversal attempt detected") - } -} - -// ============================================================================ -// Request/Response Structures -// ============================================================================ - -/// JSON-RPC request structure -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RpcRequest { - pub jsonrpc: String, - pub id: RequestId, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -impl RpcRequest { - pub fn new(id: RequestId, method: impl Into, params: Option) -> Self { - Self { - jsonrpc: JSONRPC_VERSION.to_string(), - id, - method: method.into(), - params, - } - } -} - -/// JSON-RPC response structure (success) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RpcResponse { - pub jsonrpc: String, - pub id: RequestId, - pub result: Value, -} - -impl RpcResponse { - pub fn new(id: RequestId, result: Value) -> Self { - Self { - jsonrpc: JSONRPC_VERSION.to_string(), - id, - result, - } - } -} - -/// JSON-RPC error response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RpcErrorResponse { - pub jsonrpc: String, - pub id: RequestId, - pub error: RpcError, -} - -impl RpcErrorResponse { - pub fn new(id: RequestId, error: RpcError) -> Self { - Self { - jsonrpc: JSONRPC_VERSION.to_string(), - id, - error, - } - } -} - -/// JSON‑RPC notification (no id). Used for streaming partial results. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RpcNotification { - pub jsonrpc: String, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -impl RpcNotification { - pub fn new(method: impl Into, params: Option) -> Self { - Self { - jsonrpc: JSONRPC_VERSION.to_string(), - method: method.into(), - params, - } - } -} - -/// Request ID can be string, number, or null -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(untagged)] -pub enum RequestId { - Number(u64), - String(String), -} - -impl From for RequestId { - fn from(n: u64) -> Self { - Self::Number(n) - } -} - -impl From for RequestId { - fn from(s: String) -> Self { - Self::String(s) - } -} - -// ============================================================================ -// MCP Method Names -// ============================================================================ - -/// Standard MCP methods -pub mod methods { - pub const INITIALIZE: &str = "initialize"; - pub const TOOLS_LIST: &str = "tools/list"; - pub const TOOLS_CALL: &str = "tools/call"; - pub const RESOURCES_LIST: &str = "resources_list"; - pub const RESOURCES_GET: &str = "resources_get"; - pub const RESOURCES_WRITE: &str = "resources_write"; - pub const RESOURCES_DELETE: &str = "resources_delete"; - pub const MODELS_LIST: &str = "models/list"; -} - -// ============================================================================ -// Initialization Protocol -// ============================================================================ - -/// Initialize request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InitializeParams { - pub protocol_version: String, - pub client_info: ClientInfo, - #[serde(skip_serializing_if = "Option::is_none")] - pub capabilities: Option, -} - -impl Default for InitializeParams { - fn default() -> Self { - Self { - protocol_version: PROTOCOL_VERSION.to_string(), - client_info: ClientInfo { - name: "owlen".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: None, - } - } -} - -/// Client information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClientInfo { - pub name: String, - pub version: String, -} - -/// Client capabilities -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ClientCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub supports_streaming: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub supports_cancellation: Option, -} - -/// Initialize response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InitializeResult { - pub protocol_version: String, - pub server_info: ServerInfo, - pub capabilities: ServerCapabilities, -} - -/// Server information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerInfo { - pub name: String, - pub version: String, -} - -/// Server capabilities -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ServerCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub supports_tools: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub supports_resources: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub supports_streaming: Option, -} - -// ============================================================================ -// Tool Call Protocol -// ============================================================================ - -/// Parameters for tools/list -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ToolsListParams { - #[serde(skip_serializing_if = "Option::is_none")] - pub filter: Option, -} - -/// Parameters for tools/call -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolsCallParams { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub arguments: Option, -} - -/// Result of tools/call -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolsCallResult { - pub success: bool, - pub output: Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -// ============================================================================ -// Resource Protocol -// ============================================================================ - -/// Parameters for resources/list -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourcesListParams { - pub path: String, -} - -/// Parameters for resources/get -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourcesGetParams { - pub path: String, -} - -/// Parameters for resources/write -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourcesWriteParams { - pub path: String, - pub content: String, -} - -/// Parameters for resources/delete -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourcesDeleteParams { - pub path: String, -} - -// ============================================================================ -// Versioning and Compatibility -// ============================================================================ - -/// Check if a protocol version is compatible -pub fn is_compatible(client_version: &str, server_version: &str) -> bool { - // For now, simple exact match on major version - let client_major = client_version.split('.').next().unwrap_or("0"); - let server_major = server_version.split('.').next().unwrap_or("0"); - client_major == server_major -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_codes() { - let err = RpcError::tool_not_found("test_tool"); - assert_eq!(err.code, ErrorCode::TOOL_NOT_FOUND.0); - assert!(err.message.contains("test_tool")); - } - - #[test] - fn test_version_compatibility() { - assert!(is_compatible("1.0.0", "1.0.0")); - assert!(is_compatible("1.0.0", "1.1.0")); - assert!(is_compatible("1.2.5", "1.0.0")); - assert!(!is_compatible("1.0.0", "2.0.0")); - assert!(!is_compatible("2.0.0", "1.0.0")); - } - - #[test] - fn test_request_serialization() { - let req = RpcRequest::new( - RequestId::Number(1), - "tools/call", - Some(serde_json::json!({"name": "test"})), - ); - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("\"jsonrpc\":\"2.0\"")); - assert!(json.contains("\"method\":\"tools/call\"")); - } -} diff --git a/crates/owlen-core/src/mcp/remote_client.rs b/crates/owlen-core/src/mcp/remote_client.rs deleted file mode 100644 index c1e25b5..0000000 --- a/crates/owlen-core/src/mcp/remote_client.rs +++ /dev/null @@ -1,1014 +0,0 @@ -use super::protocol::methods; -use super::protocol::{ - PROTOCOL_VERSION, RequestId, RpcErrorResponse, RpcNotification, RpcRequest, RpcResponse, -}; -use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse}; -use crate::tools::{Tool, WebScrapeTool}; -use crate::types::ModelInfo; -use crate::types::{ChatResponse, Message, Role}; -use crate::{ - ChatStream, Error, LlmProvider, Result, facade::llm_client::LlmClient, mode::Mode, - send_via_stream, -}; -use futures::{StreamExt, future::BoxFuture, stream}; -use path_clean::PathClean; -use reqwest::Client as HttpClient; -use serde_json::json; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, Command}; -use tokio::sync::Mutex; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; -use tungstenite::protocol::Message as WsMessage; - -/// Client that talks to the external `owlen-mcp-server` over STDIO, HTTP, or WebSocket. -pub struct RemoteMcpClient { - // Child process handling the server (kept alive for the duration of the client). - #[allow(dead_code)] - // For stdio transport, we keep the child process handles. - child: Option>>, - stdin: Option>>, // async write - stdout: Option>>>, - // For HTTP transport we keep a reusable client and base URL. - http_client: Option, - http_endpoint: Option, - // For WebSocket transport we keep a WebSocket stream. - ws_stream: Option>>>>, - #[allow(dead_code)] // Useful for debugging/logging - ws_endpoint: Option, - // Incrementing request identifier. - next_id: AtomicU64, - // Optional HTTP header (name, value) injected into every request. - http_header: Option<(String, String)>, - // Default RPC timeout duration in seconds - default_rpc_timeout_secs: u64, -} - -/// Runtime secrets provided when constructing an MCP client. -#[derive(Debug, Default, Clone)] -pub struct McpRuntimeSecrets { - pub env_overrides: HashMap, - pub http_header: Option<(String, String)>, -} - -/// Validates that a path is safe to access within a base directory. -/// -/// This function provides comprehensive protection against path traversal attacks by: -/// 1. Decoding URL-encoded input to prevent bypasses like `%2E%2E%2F` -/// 2. Rejecting absolute paths (including Windows UNC paths) -/// 3. Checking for null bytes (which can truncate paths in some C APIs) -/// 4. Lexically cleaning the path to remove `..` components -/// 5. Canonicalizing paths to resolve symlinks -/// 6. Verifying the final path stays within the allowed base directory -/// -/// # Security Guarantees -/// - Prevents directory traversal via `../` sequences (including URL-encoded) -/// - Prevents absolute path access (e.g., `/etc/passwd`, `C:\Windows`) -/// - Prevents symlink-based escapes from the workspace -/// - Prevents null byte injection attacks -/// -/// # Arguments -/// * `path` - The user-provided path (may be URL-encoded) -/// * `base_dir` - The base directory that all access must stay within -/// -/// # Returns -/// * `Ok(PathBuf)` - A canonicalized path guaranteed to be within `base_dir` -/// * `Err(Error)` - If the path is invalid or attempts to escape the workspace -fn validate_safe_path(path: &str, base_dir: &Path) -> Result { - // 1. Decode URL-encoded input to prevent bypass attacks like %2E%2E%2Fetc%2Fpasswd - let decoded = urlencoding::decode(path) - .map_err(|_| Error::InvalidInput("Invalid URL encoding in path".into()))? - .into_owned(); - - // 2. Reject absolute paths early (including Windows paths like C:\, /etc, \\?\UNC) - let input_path = Path::new(&decoded); - if input_path.is_absolute() { - return Err(Error::InvalidInput( - "Absolute paths not allowed - use relative paths only".into(), - )); - } - - // 3. Check for null bytes (security hazard in C FFI and some filesystems) - if decoded.contains('\0') { - return Err(Error::InvalidInput("Path contains null bytes".into())); - } - - // 4. Additional Windows-specific security checks - #[cfg(windows)] - { - // Block Windows UNC paths and device paths - if decoded.starts_with("\\\\") || decoded.starts_with("//") { - return Err(Error::InvalidInput("UNC paths not allowed".into())); - } - // Block Windows device paths - if decoded.to_lowercase().starts_with("\\\\.\\") - || decoded.to_lowercase().starts_with("//./") - { - return Err(Error::InvalidInput("Device paths not allowed".into())); - } - } - - // 5. Lexically clean the path to normalize and remove .. components - let full_path = base_dir.join(input_path); - let cleaned_path = full_path.clean(); - - // 6. Canonicalize base directory to resolve symlinks - let canonical_base = base_dir.canonicalize().map_err(|e| { - Error::Io(std::io::Error::new( - e.kind(), - format!("Failed to canonicalize workspace base directory: {}", e), - )) - })?; - - // 7. For the target path, handle both existing and non-existing files - // We need to canonicalize to resolve symlinks, but this fails for non-existent paths - let canonical_path = if cleaned_path.exists() { - // Path exists: fully canonicalize it to resolve all symlinks - cleaned_path.canonicalize().map_err(|e| { - Error::Io(std::io::Error::new( - e.kind(), - format!("Failed to canonicalize path: {}", e), - )) - })? - } else { - // Path doesn't exist yet: canonicalize the parent directory and append filename - // This handles the case where we're writing a new file - if let Some(parent) = cleaned_path.parent() { - if parent.exists() { - let canonical_parent = parent.canonicalize().map_err(|e| { - Error::Io(std::io::Error::new( - e.kind(), - format!("Failed to canonicalize parent directory: {}", e), - )) - })?; - if let Some(filename) = cleaned_path.file_name() { - canonical_parent.join(filename) - } else { - // No filename component, use cleaned path as-is - cleaned_path - } - } else { - // Parent doesn't exist - this will fail later during actual file operations - // But for security validation, we use the cleaned path - cleaned_path - } - } else { - // No parent directory, use cleaned path - cleaned_path - } - }; - - // 8. CRITICAL: Verify the final path is within the base directory - // This is the ultimate security boundary check - if !canonical_path.starts_with(&canonical_base) { - return Err(Error::InvalidInput(format!( - "Path escapes workspace boundary: attempted to access '{}'", - canonical_path.display() - ))); - } - - Ok(canonical_path) -} -impl RemoteMcpClient { - /// Spawn the MCP server binary and prepare communication channels. - /// Spawn an MCP server based on a configuration entry. - /// The `transport` field must be "stdio" (the only supported mode). - /// Spawn an external MCP server based on a configuration entry. - /// The server must communicate over STDIO (the only supported transport). - pub async fn new_with_config(config: &crate::config::McpServerConfig) -> Result { - Self::new_with_runtime(config, None).await - } - - pub async fn new_with_runtime( - config: &crate::config::McpServerConfig, - runtime: Option, - ) -> Result { - let mut runtime = runtime.unwrap_or_default(); - let transport = config.transport.to_lowercase(); - match transport.as_str() { - "stdio" => { - // Build the command using the provided binary and arguments. - let mut cmd = Command::new(config.command.clone()); - if !config.args.is_empty() { - cmd.args(config.args.clone()); - } - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()); - - // Apply environment variables defined in the configuration. - for (k, v) in config.env.iter() { - cmd.env(k, v); - } - for (k, v) in runtime.env_overrides.drain() { - cmd.env(k, v); - } - - let mut child = cmd.spawn().map_err(|e| { - Error::Io(std::io::Error::new( - e.kind(), - format!("Failed to spawn MCP server '{}': {}", config.name, e), - )) - })?; - - let stdin = child.stdin.take().ok_or_else(|| { - Error::Io(std::io::Error::other( - "Failed to capture stdin of MCP server", - )) - })?; - let stdout = child.stdout.take().ok_or_else(|| { - Error::Io(std::io::Error::other( - "Failed to capture stdout of MCP server", - )) - })?; - - Ok(Self { - child: Some(Arc::new(Mutex::new(child))), - stdin: Some(Arc::new(Mutex::new(stdin))), - stdout: Some(Arc::new(Mutex::new(BufReader::new(stdout)))), - http_client: None, - http_endpoint: None, - ws_stream: None, - ws_endpoint: None, - next_id: AtomicU64::new(1), - http_header: None, - default_rpc_timeout_secs: config.rpc_timeout_secs.unwrap_or(30), - }) - } - "http" => { - // For HTTP we treat `command` as the base URL. - let client = HttpClient::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|e| Error::Network(e.to_string()))?; - Ok(Self { - child: None, - stdin: None, - stdout: None, - http_client: Some(client), - http_endpoint: Some(config.command.clone()), - ws_stream: None, - ws_endpoint: None, - next_id: AtomicU64::new(1), - http_header: runtime.http_header.take(), - default_rpc_timeout_secs: config.rpc_timeout_secs.unwrap_or(30), - }) - } - "websocket" => { - // For WebSocket, the `command` field contains the WebSocket URL. - // Establish connection asynchronously with a timeout to avoid blocking the runtime. - let ws_url = config.command.clone(); - let connection_timeout = Duration::from_secs(30); - - let (ws_stream, _response) = - tokio::time::timeout(connection_timeout, connect_async(&ws_url)) - .await - .map_err(|_| { - Error::Timeout(format!( - "WebSocket connection to '{}' timed out after {}s", - ws_url, - connection_timeout.as_secs() - )) - })? - .map_err(|e| { - Error::Network(format!("WebSocket connection failed: {}", e)) - })?; - - Ok(Self { - child: None, - stdin: None, - stdout: None, - http_client: None, - http_endpoint: None, - ws_stream: Some(Arc::new(Mutex::new(ws_stream))), - ws_endpoint: Some(ws_url), - next_id: AtomicU64::new(1), - http_header: runtime.http_header.take(), - default_rpc_timeout_secs: config.rpc_timeout_secs.unwrap_or(30), - }) - } - other => Err(Error::NotImplemented(format!( - "Transport '{}' not supported", - other - ))), - } - } - - /// Legacy constructor kept for compatibility; attempts to locate a binary. - pub async fn new() -> Result { - // Fall back to searching for a binary as before, then delegate to new_with_config. - let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .map_err(Error::Io)?; - // Prefer the LLM server binary as it provides both LLM and resource tools. - // The generic file-server is kept as a fallback for testing. - let candidates = [ - "target/debug/owlen-mcp-llm-server", - "target/release/owlen-mcp-llm-server", - "target/debug/owlen-mcp-server", - ]; - let binary_path = candidates - .iter() - .map(|rel| workspace_root.join(rel)) - .find(|p| p.exists()) - .ok_or_else(|| { - Error::NotImplemented(format!( - "owlen-mcp server binary not found; checked {}, {}, and {}", - candidates[0], candidates[1], candidates[2] - )) - })?; - let config = crate::config::McpServerConfig { - name: "default".to_string(), - command: binary_path.to_string_lossy().into_owned(), - args: Vec::new(), - transport: "stdio".to_string(), - env: std::collections::HashMap::new(), - oauth: None, - rpc_timeout_secs: None, - }; - Self::new_with_config(&config).await - } - - /// Determine the timeout for an RPC operation based on the method name. - /// Returns the timeout duration in seconds. - fn get_timeout_for_method(&self, method: &str) -> Duration { - let seconds = match method { - // Initialize operation can take longer due to handshake - methods::INITIALIZE => 60, - // List operations should be fast - methods::TOOLS_LIST | methods::RESOURCES_LIST | methods::MODELS_LIST => 10, - // Tool execution can take a while depending on what it does - methods::TOOLS_CALL => 120, - // Default timeout for all other operations - _ => self.default_rpc_timeout_secs, - }; - Duration::from_secs(seconds) - } - - async fn send_rpc(&self, method: &str, params: serde_json::Value) -> Result { - let timeout_duration = self.get_timeout_for_method(method); - - // Wrap the entire RPC operation in a timeout - match tokio::time::timeout(timeout_duration, self.send_rpc_internal(method, params)).await { - Ok(result) => result, - Err(_) => Err(Error::Timeout(format!( - "MCP RPC call '{}' timed out after {}s", - method, - timeout_duration.as_secs() - ))), - } - } - - async fn send_rpc_internal( - &self, - method: &str, - params: serde_json::Value, - ) -> Result { - let id = RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed)); - let request = RpcRequest::new(id.clone(), method, Some(params)); - let req_str = serde_json::to_string(&request)? + "\n"; - // For stdio transport we forward the request to the child process. - if let Some(stdin_arc) = &self.stdin { - let mut stdin = stdin_arc.lock().await; - stdin.write_all(req_str.as_bytes()).await?; - stdin.flush().await?; - } - // Read a single line response - // Handle based on selected transport. - if let Some(client) = &self.http_client { - // HTTP: POST JSON body to endpoint. - let endpoint = self - .http_endpoint - .as_ref() - .ok_or_else(|| Error::Network("Missing HTTP endpoint".into()))?; - let mut builder = client.post(endpoint); - if let Some((ref header_name, ref header_value)) = self.http_header { - builder = builder.header(header_name, header_value); - } - let resp = builder - .json(&request) - .send() - .await - .map_err(|e| Error::Network(e.to_string()))?; - let text = resp - .text() - .await - .map_err(|e| Error::Network(e.to_string()))?; - // Try to parse as success then error. - if let Ok(r) = serde_json::from_str::(&text) - && r.id == id - { - return Ok(r.result); - } - let err_resp: RpcErrorResponse = - serde_json::from_str(&text).map_err(Error::Serialization)?; - return Err(Error::Network(format!( - "MCP server error {}: {}", - err_resp.error.code, err_resp.error.message - ))); - } - - // WebSocket path. - if let Some(ws_arc) = &self.ws_stream { - use futures::SinkExt; - - let mut ws = ws_arc.lock().await; - - // Send request as text message - let req_json = serde_json::to_string(&request)?; - ws.send(WsMessage::Text(req_json)) - .await - .map_err(|e| Error::Network(format!("WebSocket send failed: {}", e)))?; - - // Read response - let response_msg = ws - .next() - .await - .ok_or_else(|| Error::Network("WebSocket stream closed".into()))? - .map_err(|e| Error::Network(format!("WebSocket receive failed: {}", e)))?; - - let response_text = match response_msg { - WsMessage::Text(text) => text, - WsMessage::Binary(data) => String::from_utf8(data).map_err(|e| { - Error::Network(format!("Invalid UTF-8 in binary message: {}", e)) - })?, - WsMessage::Close(_) => { - return Err(Error::Network( - "WebSocket connection closed by server".into(), - )); - } - _ => return Err(Error::Network("Unexpected WebSocket message type".into())), - }; - - // Try to parse as success then error. - if let Ok(r) = serde_json::from_str::(&response_text) - && r.id == id - { - return Ok(r.result); - } - let err_resp: RpcErrorResponse = - serde_json::from_str(&response_text).map_err(Error::Serialization)?; - return Err(Error::Network(format!( - "MCP server error {}: {}", - err_resp.error.code, err_resp.error.message - ))); - } - - // STDIO path (default). - // Loop to skip notifications and find the response with matching ID. - loop { - let mut line = String::new(); - { - let mut stdout = self - .stdout - .as_ref() - .ok_or_else(|| Error::Network("STDIO stdout not available".into()))? - .lock() - .await; - stdout.read_line(&mut line).await?; - } - - // Try to parse as notification first (has no id field) - if let Ok(_notif) = serde_json::from_str::(&line) { - // Skip notifications and continue reading - continue; - } - - // Try to parse successful response - if let Ok(resp) = serde_json::from_str::(&line) { - if resp.id == id { - return Ok(resp.result); - } - // If ID doesn't match, continue (though this shouldn't happen) - continue; - } - - // Fallback to error response - if let Ok(err_resp) = serde_json::from_str::(&line) { - return Err(Error::Network(format!( - "MCP server error {}: {}", - err_resp.error.code, err_resp.error.message - ))); - } - - // If we can't parse as any known type, return error - return Err(Error::Network(format!( - "Unable to parse server response: {}", - line.trim() - ))); - } - } -} - -impl RemoteMcpClient { - /// Convenience wrapper delegating to the `McpClient` trait methods. - pub async fn list_tools(&self) -> Result> { - ::list_tools(self).await - } - - pub async fn call_tool(&self, call: McpToolCall) -> Result { - ::call_tool(self, call).await - } -} - -#[async_trait::async_trait] -impl McpClient for RemoteMcpClient { - async fn list_tools(&self) -> Result> { - // Query the remote MCP server for its tool descriptors using the standard - // `tools/list` RPC method. The server returns a JSON array of - // `McpToolDescriptor` objects. - let result = self.send_rpc(methods::TOOLS_LIST, json!(null)).await?; - let descriptors: Vec = serde_json::from_value(result)?; - Ok(descriptors) - } - - async fn call_tool(&self, call: McpToolCall) -> Result { - // Local handling for simple resource tools to avoid needing the MCP server - // to implement them. - if call.name.starts_with("resources_get") { - let path = call - .arguments - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| Error::InvalidInput("path missing".into()))?; - - // Secure path validation to prevent path traversal attacks - let base_dir = std::env::current_dir().map_err(Error::Io)?; - let safe_path = validate_safe_path(path, &base_dir)?; - - let content = std::fs::read_to_string(safe_path).map_err(Error::Io)?; - return Ok(McpToolResponse { - name: call.name, - success: true, - output: serde_json::json!(content), - metadata: std::collections::HashMap::new(), - duration_ms: 0, - }); - } - if call.name.starts_with("resources_list") { - let path = call - .arguments - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("."); - - // Secure path validation to prevent path traversal attacks - let base_dir = std::env::current_dir().map_err(Error::Io)?; - let safe_path = validate_safe_path(path, &base_dir)?; - - let mut names = Vec::new(); - for entry in std::fs::read_dir(safe_path).map_err(Error::Io)?.flatten() { - if let Some(name) = entry.file_name().to_str() { - names.push(name.to_string()); - } - } - return Ok(McpToolResponse { - name: call.name, - success: true, - output: serde_json::json!(names), - metadata: std::collections::HashMap::new(), - duration_ms: 0, - }); - } - // Handle write and delete resources locally as well. - if call.name.starts_with("resources_write") { - let path = call - .arguments - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| Error::InvalidInput("path missing".into()))?; - - // Secure path validation to prevent path traversal attacks - let base_dir = std::env::current_dir().map_err(Error::Io)?; - let safe_path = validate_safe_path(path, &base_dir)?; - - let content = call - .arguments - .get("content") - .and_then(|v| v.as_str()) - .ok_or_else(|| Error::InvalidInput("content missing".into()))?; - std::fs::write(safe_path, content).map_err(Error::Io)?; - return Ok(McpToolResponse { - name: call.name, - success: true, - output: serde_json::json!(null), - metadata: std::collections::HashMap::new(), - duration_ms: 0, - }); - } - if call.name.starts_with("resources_delete") { - let path = call - .arguments - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| Error::InvalidInput("path missing".into()))?; - - // Secure path validation to prevent path traversal attacks - let base_dir = std::env::current_dir().map_err(Error::Io)?; - let safe_path = validate_safe_path(path, &base_dir)?; - - std::fs::remove_file(safe_path).map_err(Error::Io)?; - return Ok(McpToolResponse { - name: call.name, - success: true, - output: serde_json::json!(null), - metadata: std::collections::HashMap::new(), - duration_ms: 0, - }); - } - if call.name == "web_scrape" { - let tool = WebScrapeTool::new(); - let result = tool - .execute(call.arguments.clone()) - .await - .map_err(|e| Error::Provider(e.into()))?; - return Ok(McpToolResponse { - name: call.name, - success: true, - output: result.output, - metadata: std::collections::HashMap::new(), - duration_ms: result.duration.as_millis() as u128, - }); - } - // MCP server expects a generic "tools/call" method with a payload containing the - // specific tool name and its arguments. Wrap the incoming call accordingly. - let payload = serde_json::to_value(&call)?; - let result = self.send_rpc(methods::TOOLS_CALL, payload).await?; - // The server returns an McpToolResponse; deserialize it. - let response: McpToolResponse = serde_json::from_value(result)?; - Ok(response) - } - - async fn set_mode(&self, _mode: Mode) -> Result<()> { - // Remote servers manage their own mode settings; treat as best-effort no-op. - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Provider implementation – forwards chat requests to the generate_text tool. -// --------------------------------------------------------------------------- - -impl LlmProvider for RemoteMcpClient { - type Stream = stream::Iter>>; - type ListModelsFuture<'a> = BoxFuture<'a, Result>>; - type SendPromptFuture<'a> = BoxFuture<'a, Result>; - type StreamPromptFuture<'a> = BoxFuture<'a, Result>; - type HealthCheckFuture<'a> = BoxFuture<'a, Result<()>>; - - fn name(&self) -> &str { - "mcp-llm-server" - } - - fn list_models(&self) -> Self::ListModelsFuture<'_> { - Box::pin(async move { - let result = self.send_rpc(methods::MODELS_LIST, json!(null)).await?; - let models: Vec = serde_json::from_value(result)?; - Ok(models) - }) - } - - fn send_prompt(&self, request: crate::types::ChatRequest) -> Self::SendPromptFuture<'_> { - Box::pin(send_via_stream(self, request)) - } - - fn stream_prompt(&self, request: crate::types::ChatRequest) -> Self::StreamPromptFuture<'_> { - Box::pin(async move { - let args = serde_json::json!({ - "messages": request.messages, - "temperature": request.parameters.temperature, - "max_tokens": request.parameters.max_tokens, - "model": request.model, - "stream": request.parameters.stream, - }); - let call = McpToolCall { - name: "generate_text".to_string(), - arguments: args, - }; - let resp = self.call_tool(call).await?; - let content = resp.output.as_str().unwrap_or("").to_string(); - let message = Message::new(Role::Assistant, content); - let chat_resp = ChatResponse { - message, - usage: None, - is_streaming: false, - is_final: true, - }; - Ok(stream::iter(vec![Ok(chat_resp)])) - }) - } - - fn health_check(&self) -> Self::HealthCheckFuture<'_> { - Box::pin(async move { - let params = serde_json::json!({ - "protocol_version": PROTOCOL_VERSION, - "client_info": { - "name": "owlen", - "version": env!("CARGO_PKG_VERSION"), - }, - "capabilities": {} - }); - self.send_rpc(methods::INITIALIZE, params).await.map(|_| ()) - }) - } -} - -#[async_trait::async_trait] -impl LlmClient for RemoteMcpClient { - async fn list_models(&self) -> Result> { - ::list_models(self).await - } - - async fn send_chat(&self, request: crate::types::ChatRequest) -> Result { - ::send_prompt(self, request).await - } - - async fn stream_chat(&self, request: crate::types::ChatRequest) -> Result { - let stream = ::stream_prompt(self, request).await?; - Ok(Box::pin(stream)) - } - - async fn list_tools(&self) -> Result> { - ::list_tools(self).await - } - - async fn call_tool(&self, call: McpToolCall) -> Result { - ::call_tool(self, call).await - } -} - -#[cfg(test)] -mod path_security_tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - /// Test that URL-encoded parent directory traversal attempts are blocked - #[test] - fn test_rejects_url_encoded_parent_dir() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - // Various URL-encoded attempts to traverse to parent directory - let attack_vectors = vec![ - "%2E%2E%2Fetc%2Fpasswd", // ../etc/passwd - "%2E%2E%2F%2E%2E%2Fetc%2Fpasswd", // ../../etc/passwd - "subdir%2F%2E%2E%2F%2E%2E%2Fetc%2Fpasswd", // subdir/../../etc/passwd - "%2e%2e%2f%2e%2e%2fetc", // lowercase encoding - ]; - - for vector in attack_vectors { - let result = validate_safe_path(vector, base); - assert!( - result.is_err(), - "Should reject URL-encoded traversal: {}", - vector - ); - } - } - - /// Test that absolute paths are rejected - #[test] - fn test_rejects_absolute_paths() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - // Unix absolute paths - assert!(validate_safe_path("/etc/passwd", base).is_err()); - assert!(validate_safe_path("/tmp/evil", base).is_err()); - assert!(validate_safe_path("/", base).is_err()); - - // Windows absolute paths (test on all platforms for consistency) - #[cfg(windows)] - { - assert!(validate_safe_path("C:\\Windows\\System32", base).is_err()); - assert!(validate_safe_path("C:/Windows/System32", base).is_err()); - assert!(validate_safe_path("D:\\", base).is_err()); - } - } - - /// Test that Windows UNC paths are rejected - #[test] - #[cfg(windows)] - fn test_rejects_unc_paths() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - let unc_paths = vec![ - "\\\\server\\share\\file.txt", - "\\\\?\\C:\\Windows\\System32", - "\\\\?\\UNC\\server\\share", - "//server/share/file.txt", - ]; - - for path in unc_paths { - let result = validate_safe_path(path, base); - assert!(result.is_err(), "Should reject UNC path: {}", path); - } - } - - /// Test that null byte injection is blocked - #[test] - fn test_rejects_null_bytes() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - let null_byte_attacks = vec![ - "file.txt\0.jpg", - "safe\0../../etc/passwd", - "\0etc/passwd", - "file\0\0", - ]; - - for attack in null_byte_attacks { - let result = validate_safe_path(attack, base); - assert!( - result.is_err(), - "Should reject null byte injection: {:?}", - attack - ); - } - } - - /// Test that valid relative paths are accepted - #[test] - fn test_accepts_valid_relative_paths() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - // Create test subdirectories - let subdir = base.join("subdir"); - fs::create_dir(&subdir).unwrap(); - let nested = subdir.join("nested"); - fs::create_dir(&nested).unwrap(); - - // Valid paths that should be accepted - let valid_paths = vec![ - "file.txt", - "subdir/file.txt", - "subdir/nested/file.txt", - "./file.txt", - "./subdir/file.txt", - ]; - - for path in valid_paths { - let result = validate_safe_path(path, base); - assert!( - result.is_ok(), - "Should accept valid relative path: {}", - path - ); - // Verify the result is within base directory - let safe_path = result.unwrap(); - assert!( - safe_path.starts_with(base), - "Validated path should be within base directory: {:?}", - safe_path - ); - } - } - - /// Test that path traversal with .. is blocked - #[test] - fn test_rejects_dot_dot_traversal() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - let traversal_attempts = vec![ - "../etc/passwd", - "../../etc/passwd", - "subdir/../../etc/passwd", - "./../../etc/passwd", - "subdir/../../../etc/passwd", - ]; - - for attempt in traversal_attempts { - let result = validate_safe_path(attempt, base); - assert!(result.is_err(), "Should reject .. traversal: {}", attempt); - } - } - - /// Test that symlink traversal is prevented - #[test] - #[cfg(unix)] // Symlink test only on Unix - fn test_prevents_symlink_escape() { - use std::os::unix::fs as unix_fs; - - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - // Create a directory outside the workspace - let external_dir = TempDir::new().unwrap(); - let external_file = external_dir.path().join("secret.txt"); - fs::write(&external_file, "secret data").unwrap(); - - // Create a symlink inside the workspace that points outside - let symlink_path = base.join("evil_link"); - unix_fs::symlink(external_dir.path(), &symlink_path).unwrap(); - - // Attempt to access the external file through the symlink - let result = validate_safe_path("evil_link/secret.txt", base); - - // Should be rejected because it resolves to a path outside the workspace - assert!( - result.is_err(), - "Should prevent symlink escape to external directory" - ); - } - - /// Test that mixed encoding and traversal attempts are blocked - #[test] - fn test_rejects_mixed_attack_vectors() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - let mixed_attacks = vec![ - "%2E%2E/etc/passwd", // Mix URL-encoded and plain - "../%2E%2E/etc/passwd", // Mix plain and URL-encoded - ".%2F..%2Fetc%2Fpasswd", // Encoded slashes with dots - ]; - - for attack in mixed_attacks { - let result = validate_safe_path(attack, base); - assert!(result.is_err(), "Should reject mixed attack: {}", attack); - } - } - - /// Test that path validation works for non-existent files (write case) - #[test] - fn test_validates_non_existent_file_in_existing_dir() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - // Create a subdirectory - let subdir = base.join("subdir"); - fs::create_dir(&subdir).unwrap(); - - // Validate path to non-existent file in existing directory - let result = validate_safe_path("subdir/newfile.txt", base); - assert!( - result.is_ok(), - "Should accept non-existent file in existing directory" - ); - - let safe_path = result.unwrap(); - assert!( - safe_path.starts_with(base), - "Path should be within base directory" - ); - assert_eq!(safe_path.file_name().unwrap(), "newfile.txt"); - } - - /// Test length limits to prevent DoS - #[test] - fn test_handles_excessively_long_paths() { - let temp_dir = TempDir::new().unwrap(); - let base = temp_dir.path(); - - // Create an excessively long path (typical filesystem limit is 4096) - let long_component = "a".repeat(300); - let long_path = format!( - "{}/{}/{}/{}", - long_component, long_component, long_component, long_component - ); - - // This should either succeed or fail gracefully with an error, not panic - let result = validate_safe_path(&long_path, base); - // We don't assert success or failure, just that it doesn't panic - // The behavior depends on filesystem limits - let _ = result; - } - - /// Test that canonicalization errors are handled properly - #[test] - fn test_handles_invalid_base_directory() { - let non_existent_base = PathBuf::from("/this/path/does/not/exist/at/all"); - - let result = validate_safe_path("file.txt", &non_existent_base); - assert!( - result.is_err(), - "Should fail when base directory doesn't exist" - ); - } - - /// Integration test: Test with actual resource operations - #[tokio::test] - async fn test_integration_resources_write_blocks_traversal() { - let temp_dir = TempDir::new().unwrap(); - std::env::set_current_dir(temp_dir.path()).unwrap(); - - // Verify our validation logic would catch path traversal attempts - // (Full integration testing would require a running MCP client) - let base_dir = std::env::current_dir().unwrap(); - let result = validate_safe_path("../../../etc/passwd", &base_dir); - assert!( - result.is_err(), - "Integration: should block path traversal in resources_write" - ); - } -} diff --git a/crates/owlen-core/src/mode.rs b/crates/owlen-core/src/mode.rs deleted file mode 100644 index fe3f77b..0000000 --- a/crates/owlen-core/src/mode.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! Operating modes for Owlen -//! -//! Defines the different modes in which Owlen can operate and their associated -//! tool availability policies. - -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -use crate::tools::{WEB_SEARCH_TOOL_NAME, canonical_tool_name}; - -/// Operating mode for Owlen -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum Mode { - /// Chat mode - limited tool access, safe for general conversation - #[default] - Chat, - /// Code mode - full tool access for development tasks - Code, -} - -impl Mode { - /// Get the display name for this mode - pub fn display_name(&self) -> &'static str { - match self { - Mode::Chat => "chat", - Mode::Code => "code", - } - } -} - -impl std::fmt::Display for Mode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.display_name()) - } -} - -impl FromStr for Mode { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "chat" => Ok(Mode::Chat), - "code" => Ok(Mode::Code), - _ => Err(format!( - "Invalid mode: '{}'. Valid modes are 'chat' or 'code'", - s - )), - } - } -} - -/// Configuration for tool availability in different modes -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModeConfig { - /// Tools allowed in chat mode - #[serde(default = "ModeConfig::default_chat_tools")] - pub chat: ModeToolConfig, - /// Tools allowed in code mode - #[serde(default = "ModeConfig::default_code_tools")] - pub code: ModeToolConfig, -} - -impl Default for ModeConfig { - fn default() -> Self { - Self { - chat: Self::default_chat_tools(), - code: Self::default_code_tools(), - } - } -} - -impl ModeConfig { - fn default_chat_tools() -> ModeToolConfig { - ModeToolConfig { - allowed_tools: vec![WEB_SEARCH_TOOL_NAME.to_string()], - } - } - - fn default_code_tools() -> ModeToolConfig { - ModeToolConfig { - allowed_tools: vec!["*".to_string()], // All tools allowed - } - } - - /// Check if a tool is allowed in the given mode - pub fn is_tool_allowed(&self, mode: Mode, tool_name: &str) -> bool { - let config = match mode { - Mode::Chat => &self.chat, - Mode::Code => &self.code, - }; - - config.is_tool_allowed(tool_name) - } -} - -/// Tool configuration for a specific mode -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModeToolConfig { - /// List of allowed tools. Use "*" to allow all tools. - pub allowed_tools: Vec, -} - -impl ModeToolConfig { - /// Check if a tool is allowed in this mode - pub fn is_tool_allowed(&self, tool_name: &str) -> bool { - // Check for wildcard - if self.allowed_tools.iter().any(|t| t == "*") { - return true; - } - - // Check if tool is explicitly listed - let target = canonical_tool_name(tool_name); - self.allowed_tools - .iter() - .any(|t| canonical_tool_name(t) == target) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_mode_display() { - assert_eq!(Mode::Chat.to_string(), "chat"); - assert_eq!(Mode::Code.to_string(), "code"); - } - - #[test] - fn test_mode_from_str() { - assert_eq!("chat".parse::(), Ok(Mode::Chat)); - assert_eq!("code".parse::(), Ok(Mode::Code)); - assert_eq!("CHAT".parse::(), Ok(Mode::Chat)); - assert_eq!("CODE".parse::(), Ok(Mode::Code)); - assert!("invalid".parse::().is_err()); - } - - #[test] - fn test_default_mode() { - assert_eq!(Mode::default(), Mode::Chat); - } - - #[test] - fn test_chat_mode_restrictions() { - let config = ModeConfig::default(); - - // Web search should be allowed in chat mode - assert!(config.is_tool_allowed(Mode::Chat, WEB_SEARCH_TOOL_NAME)); - assert!(config.is_tool_allowed(Mode::Chat, "web_search")); - - // Code exec should not be allowed in chat mode - assert!(!config.is_tool_allowed(Mode::Chat, "code_exec")); - assert!(!config.is_tool_allowed(Mode::Chat, "file_write")); - } - - #[test] - fn test_code_mode_allows_all() { - let config = ModeConfig::default(); - - // All tools should be allowed in code mode - assert!(config.is_tool_allowed(Mode::Code, WEB_SEARCH_TOOL_NAME)); - assert!(config.is_tool_allowed(Mode::Code, "web_search")); - assert!(config.is_tool_allowed(Mode::Code, "code_exec")); - assert!(config.is_tool_allowed(Mode::Code, "file_write")); - assert!(config.is_tool_allowed(Mode::Code, "anything")); - } - - #[test] - fn test_wildcard_tool_config() { - let config = ModeToolConfig { - allowed_tools: vec!["*".to_string()], - }; - - assert!(config.is_tool_allowed("any_tool")); - assert!(config.is_tool_allowed("another_tool")); - } - - #[test] - fn test_explicit_tool_list() { - let config = ModeToolConfig { - allowed_tools: vec!["tool1".to_string(), "tool2".to_string()], - }; - - assert!(config.is_tool_allowed("tool1")); - assert!(config.is_tool_allowed("tool2")); - assert!(!config.is_tool_allowed("tool3")); - } -} diff --git a/crates/owlen-core/src/model.rs b/crates/owlen-core/src/model.rs deleted file mode 100644 index f822e7a..0000000 --- a/crates/owlen-core/src/model.rs +++ /dev/null @@ -1,209 +0,0 @@ -pub mod details; - -pub use details::{DetailedModelInfo, ModelInfoRetrievalError}; - -use crate::Result; -use crate::types::ModelInfo; -use std::collections::HashMap; -use std::future::Future; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -#[derive(Default, Debug)] -struct ModelCache { - models: Vec, - last_refresh: Option, -} - -/// Caches model listings for improved selection performance -#[derive(Clone, Debug)] -pub struct ModelManager { - cache: Arc>, - ttl: Duration, -} - -impl ModelManager { - /// Create a new manager with the desired cache TTL - pub fn new(ttl: Duration) -> Self { - Self { - cache: Arc::new(RwLock::new(ModelCache::default())), - ttl, - } - } - - /// Get cached models, refreshing via the provided fetcher when stale. Returns the up-to-date model list. - pub async fn get_or_refresh( - &self, - force_refresh: bool, - fetcher: F, - ) -> Result> - where - F: FnOnce() -> Fut, - Fut: Future>>, - { - if let (false, Some(models)) = (force_refresh, self.cached_if_fresh().await) { - return Ok(models); - } - - let models = fetcher().await?; - let mut cache = self.cache.write().await; - cache.models = models.clone(); - cache.last_refresh = Some(Instant::now()); - Ok(models) - } - - /// Return cached models without refreshing - pub async fn cached(&self) -> Vec { - self.cache.read().await.models.clone() - } - - /// Drop cached models, forcing next call to refresh - pub async fn invalidate(&self) { - let mut cache = self.cache.write().await; - cache.models.clear(); - cache.last_refresh = None; - } - - /// Select a model by id or name from the cache - pub async fn select(&self, identifier: &str) -> Option { - let cache = self.cache.read().await; - cache - .models - .iter() - .find(|m| m.id == identifier || m.name == identifier) - .cloned() - } - - async fn cached_if_fresh(&self) -> Option> { - let cache = self.cache.read().await; - let fresh = matches!(cache.last_refresh, Some(ts) if ts.elapsed() < self.ttl); - if fresh && !cache.models.is_empty() { - Some(cache.models.clone()) - } else { - None - } - } -} - -#[derive(Default, Debug)] -struct ModelDetailsCacheInner { - by_key: HashMap, - name_to_key: HashMap, - fetched_at: HashMap, -} - -/// Cache for rich model details, indexed by digest when available. -#[derive(Clone, Debug)] -pub struct ModelDetailsCache { - inner: Arc>, - ttl: Duration, -} - -impl ModelDetailsCache { - /// Create a new details cache with the provided TTL. - pub fn new(ttl: Duration) -> Self { - Self { - inner: Arc::new(RwLock::new(ModelDetailsCacheInner::default())), - ttl, - } - } - - /// Try to read cached details for the provided model name. - pub async fn get(&self, name: &str) -> Option { - let mut inner = self.inner.write().await; - let key = inner.name_to_key.get(name).cloned()?; - let stale = inner - .fetched_at - .get(&key) - .is_some_and(|ts| ts.elapsed() >= self.ttl); - if stale { - inner.by_key.remove(&key); - inner.name_to_key.remove(name); - inner.fetched_at.remove(&key); - return None; - } - inner.by_key.get(&key).cloned() - } - - /// Cache the provided details, overwriting existing entries. - pub async fn insert(&self, info: DetailedModelInfo) { - let key = info.digest.clone().unwrap_or_else(|| info.name.clone()); - let mut inner = self.inner.write().await; - - // Remove prior mappings for this model name (possibly different digest). - if let Some(previous_key) = inner.name_to_key.get(&info.name).cloned() - && previous_key != key - { - inner.by_key.remove(&previous_key); - inner.fetched_at.remove(&previous_key); - } - - inner.fetched_at.insert(key.clone(), Instant::now()); - inner.name_to_key.insert(info.name.clone(), key.clone()); - inner.by_key.insert(key, info); - } - - /// Remove a specific model from the cache. - pub async fn invalidate(&self, name: &str) { - let mut inner = self.inner.write().await; - if let Some(key) = inner.name_to_key.remove(name) { - inner.by_key.remove(&key); - inner.fetched_at.remove(&key); - } - } - - /// Clear the entire cache. - pub async fn invalidate_all(&self) { - let mut inner = self.inner.write().await; - inner.by_key.clear(); - inner.name_to_key.clear(); - inner.fetched_at.clear(); - } - - /// Return all cached values regardless of freshness. - pub async fn cached(&self) -> Vec { - let inner = self.inner.read().await; - inner.by_key.values().cloned().collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - use tokio::time::sleep; - - fn sample_details(name: &str) -> DetailedModelInfo { - DetailedModelInfo { - name: name.to_string(), - ..Default::default() - } - } - - #[tokio::test] - async fn model_details_cache_returns_cached_entry() { - let cache = ModelDetailsCache::new(Duration::from_millis(50)); - let info = sample_details("llama"); - cache.insert(info.clone()).await; - let cached = cache.get("llama").await; - assert!(cached.is_some()); - assert_eq!(cached.unwrap().name, "llama"); - } - - #[tokio::test] - async fn model_details_cache_expires_based_on_ttl() { - let cache = ModelDetailsCache::new(Duration::from_millis(10)); - cache.insert(sample_details("phi")).await; - sleep(Duration::from_millis(30)).await; - assert!(cache.get("phi").await.is_none()); - } - - #[tokio::test] - async fn model_details_cache_invalidate_removes_entry() { - let cache = ModelDetailsCache::new(Duration::from_secs(1)); - cache.insert(sample_details("mistral")).await; - cache.invalidate("mistral").await; - assert!(cache.get("mistral").await.is_none()); - } -} diff --git a/crates/owlen-core/src/model/details.rs b/crates/owlen-core/src/model/details.rs deleted file mode 100644 index c6c4adb..0000000 --- a/crates/owlen-core/src/model/details.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Detailed model metadata for provider inspection features. -//! -//! These types capture richer information about locally available models -//! than the lightweight [`crate::types::ModelInfo`] listing and back the -//! higher-level inspection UI exposed in the Owlen TUI. - -use serde::{Deserialize, Serialize}; - -/// Rich metadata about an Ollama model. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DetailedModelInfo { - /// Canonical model name (including tag). - pub name: String, - /// Reported architecture or model format. - #[serde(skip_serializing_if = "Option::is_none")] - pub architecture: Option, - /// Human-readable parameter / quantisation summary. - #[serde(skip_serializing_if = "Option::is_none")] - pub parameters: Option, - /// Context window length, if provided. - #[serde(skip_serializing_if = "Option::is_none")] - pub context_length: Option, - /// Embedding vector length for embedding-capable models. - #[serde(skip_serializing_if = "Option::is_none")] - pub embedding_length: Option, - /// Quantisation level (e.g., Q4_0, Q5_K_M). - #[serde(skip_serializing_if = "Option::is_none")] - pub quantization: Option, - /// Primary family identifier (e.g., llama3). - #[serde(skip_serializing_if = "Option::is_none")] - pub family: Option, - /// Additional family tags reported by Ollama. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub families: Vec, - /// Verbose parameter size description (e.g., 70B parameters). - #[serde(skip_serializing_if = "Option::is_none")] - pub parameter_size: Option, - /// Default prompt template packaged with the model. - #[serde(skip_serializing_if = "Option::is_none")] - pub template: Option, - /// Default system prompt packaged with the model. - #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, - /// License string provided by the model. - #[serde(skip_serializing_if = "Option::is_none")] - pub license: Option, - /// Raw modelfile contents (if available). - #[serde(skip_serializing_if = "Option::is_none")] - pub modelfile: Option, - /// Modification timestamp (ISO-8601) if reported. - #[serde(skip_serializing_if = "Option::is_none")] - pub modified_at: Option, - /// Approximate model size in bytes. - #[serde(skip_serializing_if = "Option::is_none")] - pub size: Option, - /// Digest / checksum used by Ollama (sha256). - #[serde(skip_serializing_if = "Option::is_none")] - pub digest: Option, -} - -impl DetailedModelInfo { - /// Convenience helper that normalises empty strings to `None`. - pub fn with_normalised_strings(mut self) -> Self { - if self.architecture.as_ref().is_some_and(String::is_empty) { - self.architecture = None; - } - if self.parameters.as_ref().is_some_and(String::is_empty) { - self.parameters = None; - } - if self.quantization.as_ref().is_some_and(String::is_empty) { - self.quantization = None; - } - if self.family.as_ref().is_some_and(String::is_empty) { - self.family = None; - } - if self.parameter_size.as_ref().is_some_and(String::is_empty) { - self.parameter_size = None; - } - if self.template.as_ref().is_some_and(String::is_empty) { - self.template = None; - } - if self.system.as_ref().is_some_and(String::is_empty) { - self.system = None; - } - if self.license.as_ref().is_some_and(String::is_empty) { - self.license = None; - } - if self.modelfile.as_ref().is_some_and(String::is_empty) { - self.modelfile = None; - } - if self.digest.as_ref().is_some_and(String::is_empty) { - self.digest = None; - } - self - } -} - -/// Error payload returned when model inspection fails for a specific model. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelInfoRetrievalError { - /// Model that failed to resolve. - pub model_name: String, - /// Human-readable description of the failure. - pub error_message: String, -} diff --git a/crates/owlen-core/src/oauth.rs b/crates/owlen-core/src/oauth.rs deleted file mode 100644 index 56be6e3..0000000 --- a/crates/owlen-core/src/oauth.rs +++ /dev/null @@ -1,507 +0,0 @@ -use std::time::Duration as StdDuration; - -use chrono::{DateTime, Duration, Utc}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; - -use crate::{Error, Result, config::McpOAuthConfig}; - -/// Persisted OAuth token set for MCP servers and providers. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -pub struct OAuthToken { - /// Bearer access token returned by the authorization server. - pub access_token: String, - /// Optional refresh token if the provider issues one. - #[serde(default)] - pub refresh_token: Option, - /// Absolute UTC expiration timestamp for the access token. - #[serde(default)] - pub expires_at: Option>, - /// Optional space-delimited scope string supplied by the provider. - #[serde(default)] - pub scope: Option, - /// Token type reported by the provider (typically `Bearer`). - #[serde(default)] - pub token_type: Option, -} - -impl OAuthToken { - /// Returns `true` if the access token has expired at the provided instant. - pub fn is_expired(&self, now: DateTime) -> bool { - matches!(self.expires_at, Some(expiry) if now >= expiry) - } - - /// Returns `true` if the token will expire within the supplied duration window. - pub fn will_expire_within(&self, window: Duration, now: DateTime) -> bool { - matches!(self.expires_at, Some(expiry) if expiry - now <= window) - } -} - -/// Active device-authorization session details returned by the authorization server. -#[derive(Debug, Clone)] -pub struct DeviceAuthorization { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - pub verification_uri_complete: Option, - pub expires_at: DateTime, - pub interval: StdDuration, - pub message: Option, -} - -impl DeviceAuthorization { - pub fn is_expired(&self, now: DateTime) -> bool { - now >= self.expires_at - } -} - -/// Result of polling the token endpoint during a device-authorization flow. -#[derive(Debug, Clone)] -pub enum DevicePollState { - Pending { retry_in: StdDuration }, - Complete(OAuthToken), -} - -pub struct OAuthClient { - http: Client, - config: McpOAuthConfig, -} - -impl OAuthClient { - pub fn new(config: McpOAuthConfig) -> Result { - let http = Client::builder() - .user_agent("OwlenOAuth/1.0") - .build() - .map_err(|err| Error::Network(format!("Failed to construct HTTP client: {err}")))?; - Ok(Self { http, config }) - } - - fn scope_value(&self) -> Option { - if self.config.scopes.is_empty() { - None - } else { - Some(self.config.scopes.join(" ")) - } - } - - fn token_request_base(&self) -> Vec<(String, String)> { - let mut params = vec![("client_id".to_string(), self.config.client_id.clone())]; - if let Some(secret) = &self.config.client_secret { - params.push(("client_secret".to_string(), secret.clone())); - } - params - } - - pub async fn start_device_authorization(&self) -> Result { - let device_url = self - .config - .device_authorization_url - .as_ref() - .ok_or_else(|| { - Error::Config("Device authorization endpoint is not configured.".to_string()) - })?; - - let mut params = self.token_request_base(); - if let Some(scope) = self.scope_value() { - params.push(("scope".to_string(), scope)); - } - - let response = self - .http - .post(device_url) - .form(¶ms) - .send() - .await - .map_err(|err| map_http_error("start device authorization", err))?; - - let status = response.status(); - let payload = response - .json::() - .await - .map_err(|err| { - Error::Auth(format!( - "Failed to parse device authorization response (status {status}): {err}" - )) - })?; - - let expires_at = - Utc::now() + Duration::seconds(payload.expires_in.min(i64::MAX as u64) as i64); - let interval = StdDuration::from_secs(payload.interval.unwrap_or(5).max(1)); - - Ok(DeviceAuthorization { - device_code: payload.device_code, - user_code: payload.user_code, - verification_uri: payload.verification_uri, - verification_uri_complete: payload.verification_uri_complete, - expires_at, - interval, - message: payload.message, - }) - } - - pub async fn poll_device_token(&self, auth: &DeviceAuthorization) -> Result { - let mut params = self.token_request_base(); - params.push(("grant_type".to_string(), DEVICE_CODE_GRANT.to_string())); - params.push(("device_code".to_string(), auth.device_code.clone())); - if let Some(scope) = self.scope_value() { - params.push(("scope".to_string(), scope)); - } - - let response = self - .http - .post(&self.config.token_url) - .form(¶ms) - .send() - .await - .map_err(|err| map_http_error("poll device token", err))?; - - let status = response.status(); - let text = response - .text() - .await - .map_err(|err| map_http_error("read token response", err))?; - - if status.is_success() { - let payload: TokenResponse = serde_json::from_str(&text).map_err(|err| { - Error::Auth(format!( - "Failed to parse OAuth token response: {err}; body: {text}" - )) - })?; - return Ok(DevicePollState::Complete(oauth_token_from_response( - payload, - ))); - } - - let error = serde_json::from_str::(&text).unwrap_or_else(|_| { - OAuthErrorResponse { - error: "unknown_error".to_string(), - error_description: Some(text.clone()), - } - }); - - match error.error.as_str() { - "authorization_pending" => Ok(DevicePollState::Pending { - retry_in: auth.interval, - }), - "slow_down" => Ok(DevicePollState::Pending { - retry_in: auth.interval.saturating_add(StdDuration::from_secs(5)), - }), - "access_denied" => { - Err(Error::Auth(error.error_description.unwrap_or_else(|| { - "User declined authorization".to_string() - }))) - } - "expired_token" | "expired_device_code" => { - Err(Error::Auth(error.error_description.unwrap_or_else(|| { - "Device authorization expired".to_string() - }))) - } - other => Err(Error::Auth( - error - .error_description - .unwrap_or_else(|| format!("OAuth error: {other}")), - )), - } - } - - pub async fn refresh_token(&self, refresh_token: &str) -> Result { - let mut params = self.token_request_base(); - params.push(("grant_type".to_string(), "refresh_token".to_string())); - params.push(("refresh_token".to_string(), refresh_token.to_string())); - if let Some(scope) = self.scope_value() { - params.push(("scope".to_string(), scope)); - } - - let response = self - .http - .post(&self.config.token_url) - .form(¶ms) - .send() - .await - .map_err(|err| map_http_error("refresh OAuth token", err))?; - - let status = response.status(); - let text = response - .text() - .await - .map_err(|err| map_http_error("read refresh response", err))?; - - if status.is_success() { - let payload: TokenResponse = serde_json::from_str(&text).map_err(|err| { - Error::Auth(format!( - "Failed to parse OAuth refresh response: {err}; body: {text}" - )) - })?; - Ok(oauth_token_from_response(payload)) - } else { - let error = serde_json::from_str::(&text).unwrap_or_else(|_| { - OAuthErrorResponse { - error: "unknown_error".to_string(), - error_description: Some(text.clone()), - } - }); - Err(Error::Auth(error.error_description.unwrap_or_else(|| { - format!("OAuth token refresh failed: {}", error.error) - }))) - } - } -} - -const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; - -#[derive(Debug, Deserialize)] -struct DeviceAuthorizationResponse { - device_code: String, - user_code: String, - verification_uri: String, - #[serde(default)] - verification_uri_complete: Option, - expires_in: u64, - #[serde(default)] - interval: Option, - #[serde(default)] - message: Option, -} - -#[derive(Debug, Deserialize)] -struct TokenResponse { - access_token: String, - #[serde(default)] - refresh_token: Option, - #[serde(default)] - expires_in: Option, - #[serde(default)] - scope: Option, - #[serde(default)] - token_type: Option, -} - -#[derive(Debug, Deserialize)] -struct OAuthErrorResponse { - error: String, - #[serde(default)] - error_description: Option, -} - -fn oauth_token_from_response(payload: TokenResponse) -> OAuthToken { - let expires_at = payload - .expires_in - .map(|seconds| seconds.min(i64::MAX as u64) as i64) - .map(|seconds| Utc::now() + Duration::seconds(seconds)); - - OAuthToken { - access_token: payload.access_token, - refresh_token: payload.refresh_token, - expires_at, - scope: payload.scope, - token_type: payload.token_type, - } -} - -fn map_http_error(action: &str, err: reqwest::Error) -> Error { - if err.is_timeout() { - Error::Timeout(format!("OAuth {action} request timed out: {err}")) - } else if err.is_connect() { - Error::Network(format!("OAuth {action} connection error: {err}")) - } else { - Error::Network(format!("OAuth {action} request failed: {err}")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use httpmock::prelude::*; - use serde_json::json; - - fn config_for(server: &MockServer) -> McpOAuthConfig { - McpOAuthConfig { - client_id: "test-client".to_string(), - client_secret: None, - authorize_url: server.url("/authorize"), - token_url: server.url("/token"), - device_authorization_url: Some(server.url("/device")), - redirect_url: None, - scopes: vec!["repo".to_string(), "user".to_string()], - token_env: None, - header: None, - header_prefix: None, - } - } - - fn sample_device_authorization() -> DeviceAuthorization { - DeviceAuthorization { - device_code: "device-123".to_string(), - user_code: "ABCD-EFGH".to_string(), - verification_uri: "https://example.test/activate".to_string(), - verification_uri_complete: Some( - "https://example.test/activate?user_code=ABCD-EFGH".to_string(), - ), - expires_at: Utc::now() + Duration::minutes(10), - interval: StdDuration::from_secs(5), - message: Some("Open the verification URL and enter the code.".to_string()), - } - } - - #[tokio::test] - async fn start_device_authorization_returns_payload() { - let server = MockServer::start_async().await; - let device_mock = server - .mock_async(|when, then| { - when.method(POST).path("/device"); - then.status(200) - .header("content-type", "application/json") - .json_body(json!({ - "device_code": "device-123", - "user_code": "ABCD-EFGH", - "verification_uri": "https://example.test/activate", - "verification_uri_complete": "https://example.test/activate?user_code=ABCD-EFGH", - "expires_in": 600, - "interval": 7, - "message": "Open the verification URL and enter the code." - })); - }) - .await; - - let client = OAuthClient::new(config_for(&server)).expect("client"); - let auth = client - .start_device_authorization() - .await - .expect("device authorization payload"); - - assert_eq!(auth.user_code, "ABCD-EFGH"); - assert_eq!(auth.interval, StdDuration::from_secs(7)); - assert!(auth.expires_at > Utc::now()); - device_mock.assert_async().await; - } - - #[tokio::test] - async fn poll_device_token_reports_pending() { - let server = MockServer::start_async().await; - let pending = server - .mock_async(|when, then| { - when.method(POST) - .path("/token") - .body_contains( - "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code", - ) - .body_contains("device_code=device-123"); - then.status(400) - .header("content-type", "application/json") - .json_body(json!({ - "error": "authorization_pending" - })); - }) - .await; - - let config = config_for(&server); - let client = OAuthClient::new(config).expect("client"); - let auth = sample_device_authorization(); - - let result = client.poll_device_token(&auth).await.expect("poll result"); - match result { - DevicePollState::Pending { retry_in } => { - assert_eq!(retry_in, StdDuration::from_secs(5)); - } - other => panic!("expected pending state, got {other:?}"), - } - - pending.assert_async().await; - } - - #[tokio::test] - async fn poll_device_token_applies_slow_down_backoff() { - let server = MockServer::start_async().await; - let slow = server - .mock_async(|when, then| { - when.method(POST).path("/token"); - then.status(400) - .header("content-type", "application/json") - .json_body(json!({ - "error": "slow_down" - })); - }) - .await; - - let config = config_for(&server); - let client = OAuthClient::new(config).expect("client"); - let auth = sample_device_authorization(); - - let result = client.poll_device_token(&auth).await.expect("poll result"); - match result { - DevicePollState::Pending { retry_in } => { - assert_eq!(retry_in, StdDuration::from_secs(10)); - } - other => panic!("expected pending state, got {other:?}"), - } - - slow.assert_async().await; - } - - #[tokio::test] - async fn poll_device_token_returns_token_when_authorized() { - let server = MockServer::start_async().await; - let token = server - .mock_async(|when, then| { - when.method(POST).path("/token"); - then.status(200) - .header("content-type", "application/json") - .json_body(json!({ - "access_token": "token-abc", - "refresh_token": "refresh-xyz", - "expires_in": 3600, - "token_type": "Bearer", - "scope": "repo user" - })); - }) - .await; - - let config = config_for(&server); - let client = OAuthClient::new(config).expect("client"); - let auth = sample_device_authorization(); - - let result = client.poll_device_token(&auth).await.expect("poll result"); - let token_info = match result { - DevicePollState::Complete(token) => token, - other => panic!("expected completion, got {other:?}"), - }; - - assert_eq!(token_info.access_token, "token-abc"); - assert_eq!(token_info.refresh_token.as_deref(), Some("refresh-xyz")); - assert!(token_info.expires_at.is_some()); - token.assert_async().await; - } - - #[tokio::test] - async fn refresh_token_roundtrip() { - let server = MockServer::start_async().await; - let refresh = server - .mock_async(|when, then| { - when.method(POST) - .path("/token") - .body_contains("grant_type=refresh_token") - .body_contains("refresh_token=old-refresh"); - then.status(200) - .header("content-type", "application/json") - .json_body(json!({ - "access_token": "token-new", - "refresh_token": "refresh-new", - "expires_in": 1200, - "token_type": "Bearer" - })); - }) - .await; - - let config = config_for(&server); - let client = OAuthClient::new(config).expect("client"); - let token = client - .refresh_token("old-refresh") - .await - .expect("refresh response"); - - assert_eq!(token.access_token, "token-new"); - assert_eq!(token.refresh_token.as_deref(), Some("refresh-new")); - assert!(token.expires_at.is_some()); - refresh.assert_async().await; - } -} diff --git a/crates/owlen-core/src/provider/manager.rs b/crates/owlen-core/src/provider/manager.rs deleted file mode 100644 index ec7b150..0000000 --- a/crates/owlen-core/src/provider/manager.rs +++ /dev/null @@ -1,514 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use futures::stream::{FuturesUnordered, StreamExt}; -use log::{debug, warn}; -use serde_json::Value; -use tokio::sync::RwLock; - -use crate::config::Config; -use crate::{Error, Result}; - -use super::{ - GenerateRequest, GenerateStream, ModelInfo, ModelProvider, ProviderStatus, ProviderType, -}; - -/// Model information annotated with the originating provider metadata. -#[derive(Debug, Clone)] -pub struct AnnotatedModelInfo { - pub provider_id: String, - pub provider_status: ProviderStatus, - pub model: ModelInfo, -} - -/// Coordinates multiple [`ModelProvider`] implementations and tracks their -/// health state. -pub struct ProviderManager { - providers: RwLock>>, - status_cache: RwLock>, - last_health_check: RwLock>, - health_cache_ttl: Duration, -} - -impl ProviderManager { - /// Construct a new manager using the supplied configuration. Providers - /// defined in the configuration start with a `RequiresSetup` status so - /// that frontends can surface incomplete configuration to users. - pub fn new(config: &Config) -> Self { - let mut status_cache = HashMap::new(); - for provider_id in config.providers.keys() { - status_cache.insert(provider_id.clone(), ProviderStatus::RequiresSetup); - } - - // Use configured TTL (default 30 seconds) to reduce health check load - let health_cache_ttl = config.general.health_check_ttl(); - - Self { - providers: RwLock::new(HashMap::new()), - status_cache: RwLock::new(status_cache), - last_health_check: RwLock::new(None), - health_cache_ttl, - } - } - - /// Register a provider instance with the manager. - pub async fn register_provider(&self, provider: Arc) { - let provider_id = provider.metadata().id.clone(); - debug!("registering provider {}", provider_id); - - self.providers - .write() - .await - .insert(provider_id.clone(), provider); - self.status_cache - .write() - .await - .insert(provider_id, ProviderStatus::Unavailable); - } - - /// Return a stream by routing the request to the designated provider. - pub async fn generate( - &self, - provider_id: &str, - request: GenerateRequest, - ) -> Result { - let provider = { - let guard = self.providers.read().await; - guard.get(provider_id).cloned() - } - .ok_or_else(|| Error::Config(format!("provider '{provider_id}' not registered")))?; - - match provider.generate_stream(request).await { - Ok(stream) => { - self.status_cache - .write() - .await - .insert(provider_id.to_string(), ProviderStatus::Available); - Ok(stream) - } - Err(err) => { - self.status_cache - .write() - .await - .insert(provider_id.to_string(), ProviderStatus::Unavailable); - Err(err) - } - } - } - - /// List models across all providers, updating provider status along the way. - pub async fn list_all_models(&self) -> Result> { - let providers: Vec<(String, Arc)> = { - let guard = self.providers.read().await; - guard - .iter() - .map(|(id, provider)| (id.clone(), Arc::clone(provider))) - .collect() - }; - - let mut tasks = FuturesUnordered::new(); - - for (provider_id, provider) in providers { - tasks.push(async move { - let log_id = provider_id.clone(); - let mut status = ProviderStatus::Unavailable; - let mut models = Vec::new(); - - match provider.health_check().await { - Ok(health) => { - status = health; - if matches!(status, ProviderStatus::Available) { - match provider.list_models().await { - Ok(list) => { - models = list; - } - Err(err) => { - status = ProviderStatus::Unavailable; - warn!("listing models failed for provider {}: {}", log_id, err); - } - } - } - } - Err(err) => { - warn!("health check failed for provider {}: {}", log_id, err); - } - } - - (provider_id, status, models) - }); - } - - let mut annotated = Vec::new(); - let mut status_updates = HashMap::new(); - - while let Some((provider_id, status, models)) = tasks.next().await { - status_updates.insert(provider_id.clone(), status); - for model in models { - annotated.push(AnnotatedModelInfo { - provider_id: provider_id.clone(), - provider_status: status, - model, - }); - } - } - - { - let mut guard = self.status_cache.write().await; - for (provider_id, status) in status_updates { - guard.insert(provider_id, status); - } - } - - enrich_model_metadata(&mut annotated); - Ok(annotated) - } - - /// Refresh the health of all registered providers in parallel, returning - /// the latest status snapshot. Results are cached for the configured TTL - /// to reduce provider load. - pub async fn refresh_health(&self) -> HashMap { - // Check if cache is still fresh - { - let last_check = self.last_health_check.read().await; - if let Some(instant) = *last_check && instant.elapsed() < self.health_cache_ttl { - // Return cached status without performing checks - debug!("returning cached health status (TTL not expired)"); - return self.status_cache.read().await.clone(); - } - } - - // Cache expired or first check - perform actual health checks - debug!("cache expired, performing health checks"); - let providers: Vec<(String, Arc)> = { - let guard = self.providers.read().await; - guard - .iter() - .map(|(id, provider)| (id.clone(), Arc::clone(provider))) - .collect() - }; - - let mut tasks = FuturesUnordered::new(); - for (provider_id, provider) in providers { - tasks.push(async move { - let status = match provider.health_check().await { - Ok(status) => status, - Err(err) => { - warn!("health check failed for provider {}: {}", provider_id, err); - ProviderStatus::Unavailable - } - }; - (provider_id, status) - }); - } - - let mut updates = HashMap::new(); - while let Some((provider_id, status)) = tasks.next().await { - updates.insert(provider_id, status); - } - - { - let mut guard = self.status_cache.write().await; - for (provider_id, status) in &updates { - guard.insert(provider_id.clone(), *status); - } - } - - // Update cache timestamp - *self.last_health_check.write().await = Some(Instant::now()); - - updates - } - - /// Force a health check refresh, bypassing the cache. This is useful - /// when an immediate status update is required. - pub async fn force_refresh_health(&self) -> HashMap { - debug!("forcing health check refresh (bypassing cache)"); - *self.last_health_check.write().await = None; - self.refresh_health().await - } - - /// Return the provider instance for an identifier. - pub async fn get_provider(&self, provider_id: &str) -> Option> { - let guard = self.providers.read().await; - guard.get(provider_id).cloned() - } - - /// List the registered provider identifiers. - pub async fn provider_ids(&self) -> Vec { - let guard = self.providers.read().await; - guard.keys().cloned().collect() - } - - /// Retrieve the last known status for a provider. - pub async fn provider_status(&self, provider_id: &str) -> Option { - let guard = self.status_cache.read().await; - guard.get(provider_id).copied() - } - - /// Snapshot the currently cached statuses. - pub async fn provider_statuses(&self) -> HashMap { - let guard = self.status_cache.read().await; - guard.clone() - } -} - -fn enrich_model_metadata(models: &mut [AnnotatedModelInfo]) { - let mut name_counts: HashMap = HashMap::new(); - for info in models.iter() { - *name_counts.entry(info.model.name.clone()).or_default() += 1; - } - - for info in models.iter_mut() { - let provider_tag = provider_tag_for(&info.provider_id); - info.model - .metadata - .insert("provider_tag".into(), Value::String(provider_tag.clone())); - - let scope_label = provider_scope_label(info.model.provider.provider_type); - info.model.metadata.insert( - "provider_scope".into(), - Value::String(scope_label.to_string()), - ); - info.model.metadata.insert( - "provider_display_name".into(), - Value::String(info.model.provider.name.clone()), - ); - - let display_name = if name_counts - .get(&info.model.name) - .is_some_and(|count| *count > 1) - { - let suffix = scope_label; - let base = info.model.name.trim(); - if base.ends_with(&format!("· {}", suffix)) { - base.to_string() - } else { - format!("{base} · {suffix}") - } - } else { - info.model.name.clone() - }; - - info.model - .metadata - .insert("display_name".into(), Value::String(display_name)); - } -} - -fn provider_tag_for(provider_id: &str) -> String { - let normalized = provider_id.trim().to_ascii_lowercase().replace('-', "_"); - match normalized.as_str() { - "ollama" | "ollama_local" => "ollama".to_string(), - "ollama_cloud" => "ollama-cloud".to_string(), - other => other.replace('_', "-"), - } -} - -fn provider_scope_label(provider_type: ProviderType) -> &'static str { - match provider_type { - ProviderType::Local => "local", - ProviderType::Cloud => "cloud", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use std::sync::Arc; - - use crate::{Error, provider::ProviderMetadata}; - - #[derive(Clone)] - struct StaticProvider { - metadata: ProviderMetadata, - models: Vec, - status: ProviderStatus, - } - - impl StaticProvider { - fn new( - id: &str, - name: &str, - provider_type: ProviderType, - status: ProviderStatus, - models: Vec, - ) -> Self { - let metadata = ProviderMetadata::new(id, name, provider_type, false); - let mut models = models; - for model in &mut models { - model.provider = metadata.clone(); - } - let mut metadata = metadata; - metadata - .metadata - .insert("test".into(), Value::String("true".into())); - Self { - metadata, - models, - status, - } - } - } - - #[async_trait] - impl ModelProvider for StaticProvider { - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - - async fn health_check(&self) -> Result { - Ok(self.status) - } - - async fn list_models(&self) -> Result> { - Ok(self.models.clone()) - } - - async fn generate_stream(&self, _request: GenerateRequest) -> Result { - Err(Error::NotImplemented( - "streaming not implemented in StaticProvider".to_string(), - )) - } - } - - fn model(name: &str) -> ModelInfo { - ModelInfo { - name: name.to_string(), - size_bytes: None, - capabilities: Vec::new(), - description: None, - provider: ProviderMetadata::new("unused", "Unused", ProviderType::Local, false), - metadata: HashMap::new(), - } - } - - #[tokio::test] - async fn aggregates_local_provider_models() { - let manager = ProviderManager::default(); - let provider = StaticProvider::new( - "ollama_local", - "Ollama Local", - ProviderType::Local, - ProviderStatus::Available, - vec![model("qwen3:8b")], - ); - manager.register_provider(Arc::new(provider)).await; - - let models = manager.list_all_models().await.unwrap(); - assert_eq!(models.len(), 1); - let entry = &models[0]; - assert_eq!(entry.provider_id, "ollama_local"); - assert_eq!(entry.provider_status, ProviderStatus::Available); - assert_eq!( - entry - .model - .metadata - .get("provider_tag") - .and_then(Value::as_str), - Some("ollama") - ); - assert_eq!( - entry - .model - .metadata - .get("display_name") - .and_then(Value::as_str), - Some("qwen3:8b") - ); - } - - #[tokio::test] - async fn aggregates_cloud_provider_models() { - let manager = ProviderManager::default(); - let provider = StaticProvider::new( - "ollama_cloud", - "Ollama Cloud", - ProviderType::Cloud, - ProviderStatus::Available, - vec![model("qwen3:0.5b-cloud")], - ); - manager.register_provider(Arc::new(provider)).await; - - let models = manager.list_all_models().await.unwrap(); - assert_eq!(models.len(), 1); - let entry = &models[0]; - assert_eq!( - entry - .model - .metadata - .get("provider_tag") - .and_then(Value::as_str), - Some("ollama-cloud") - ); - assert_eq!( - entry - .model - .metadata - .get("display_name") - .and_then(Value::as_str), - Some("qwen3:0.5b-cloud") - ); - } - - #[tokio::test] - async fn deduplicates_model_names_with_provider_suffix() { - let manager = ProviderManager::default(); - let local = StaticProvider::new( - "ollama_local", - "Ollama Local", - ProviderType::Local, - ProviderStatus::Available, - vec![model("qwen3:8b")], - ); - let cloud = StaticProvider::new( - "ollama_cloud", - "Ollama Cloud", - ProviderType::Cloud, - ProviderStatus::Available, - vec![model("qwen3:8b")], - ); - manager.register_provider(Arc::new(local)).await; - manager.register_provider(Arc::new(cloud)).await; - - let models = manager.list_all_models().await.unwrap(); - - let local_entry = models - .iter() - .find(|entry| entry.provider_id == "ollama_local") - .expect("local provider entry"); - let cloud_entry = models - .iter() - .find(|entry| entry.provider_id == "ollama_cloud") - .expect("cloud provider entry"); - - assert_eq!( - local_entry - .model - .metadata - .get("display_name") - .and_then(Value::as_str), - Some("qwen3:8b · local") - ); - assert_eq!( - cloud_entry - .model - .metadata - .get("display_name") - .and_then(Value::as_str), - Some("qwen3:8b · cloud") - ); - } -} - -impl Default for ProviderManager { - fn default() -> Self { - Self { - providers: RwLock::new(HashMap::new()), - status_cache: RwLock::new(HashMap::new()), - last_health_check: RwLock::new(None), - health_cache_ttl: Duration::from_secs(30), - } - } -} diff --git a/crates/owlen-core/src/provider/mod.rs b/crates/owlen-core/src/provider/mod.rs deleted file mode 100644 index 5055ec9..0000000 --- a/crates/owlen-core/src/provider/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Unified provider abstraction layer. -//! -//! This module defines the async [`ModelProvider`] trait that all model -//! backends implement, together with a small suite of shared data structures -//! used for model discovery and streaming generation. The [`ProviderManager`] -//! orchestrates multiple providers and coordinates their health state. - -mod manager; -mod types; - -use std::pin::Pin; - -use async_trait::async_trait; -use futures::Stream; - -pub use self::{manager::*, types::*}; - -use crate::Result; - -/// Convenience alias for the stream type yielded by [`ModelProvider::generate_stream`]. -pub type GenerateStream = Pin> + Send + 'static>>; - -#[async_trait] -pub trait ModelProvider: Send + Sync { - /// Returns descriptive metadata about the provider. - fn metadata(&self) -> &ProviderMetadata; - - /// Check the current health state for the provider. - async fn health_check(&self) -> Result; - - /// List all models available through the provider. - async fn list_models(&self) -> Result>; - - /// Acquire a streaming response for a generation request. - async fn generate_stream(&self, request: GenerateRequest) -> Result; -} diff --git a/crates/owlen-core/src/provider/types.rs b/crates/owlen-core/src/provider/types.rs deleted file mode 100644 index ba283b1..0000000 --- a/crates/owlen-core/src/provider/types.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Shared types used by the unified provider abstraction layer. - -use std::{collections::HashMap, fmt}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// Categorises providers so the UI can distinguish between local and hosted -/// backends. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum ProviderType { - Local, - Cloud, -} - -/// Represents the current availability state for a provider. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum ProviderStatus { - Available, - Unavailable, - RequiresSetup, -} - -/// High-level categories for provider failures. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum ProviderErrorKind { - Unauthorized, - RateLimited, - Unavailable, - Timeout, - InvalidRequest, - ModelNotFound, - Network, - Protocol, - Unknown, -} - -impl fmt::Display for ProviderErrorKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let label = match self { - ProviderErrorKind::Unauthorized => "unauthorized", - ProviderErrorKind::RateLimited => "rate limited", - ProviderErrorKind::Unavailable => "unavailable", - ProviderErrorKind::Timeout => "timed out", - ProviderErrorKind::InvalidRequest => "invalid request", - ProviderErrorKind::ModelNotFound => "model not found", - ProviderErrorKind::Network => "network error", - ProviderErrorKind::Protocol => "protocol error", - ProviderErrorKind::Unknown => "unknown failure", - }; - write!(f, "{label}") - } -} - -/// Structured provider failure description used for UI and logs. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderError { - pub provider_id: Option, - pub kind: ProviderErrorKind, - pub message: String, - #[serde(default)] - pub detail: Option, -} - -impl ProviderError { - /// Construct a new provider error with the given category and message. - pub fn new(kind: ProviderErrorKind, message: impl Into) -> Self { - Self { - provider_id: None, - kind, - message: message.into(), - detail: None, - } - } - - /// Attach the provider identifier to the failure. - pub fn with_provider(mut self, provider_id: impl Into) -> Self { - self.provider_id = Some(provider_id.into()); - self - } - - /// Attach a detailed description to the failure. - pub fn with_detail(mut self, detail: impl Into) -> Self { - let text = detail.into(); - if !text.trim().is_empty() { - self.detail = Some(text); - } - self - } -} - -impl fmt::Display for ProviderError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match (&self.detail, &self.provider_id) { - (Some(detail), Some(provider)) => { - write!(f, "{provider}: {} ({detail})", self.message) - } - (Some(detail), None) => write!(f, "{} ({detail})", self.message), - (None, Some(provider)) => write!(f, "{provider}: {}", self.message), - (None, None) => write!(f, "{}", self.message), - } - } -} - -/// Describes core metadata for a provider implementation. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ProviderMetadata { - pub id: String, - pub name: String, - pub provider_type: ProviderType, - pub requires_auth: bool, - #[serde(default)] - pub metadata: HashMap, -} - -impl ProviderMetadata { - /// Construct a new metadata instance for a provider. - pub fn new( - id: impl Into, - name: impl Into, - provider_type: ProviderType, - requires_auth: bool, - ) -> Self { - Self { - id: id.into(), - name: name.into(), - provider_type, - requires_auth, - metadata: HashMap::new(), - } - } -} - -/// Information about a model that can be displayed to users. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ModelInfo { - pub name: String, - #[serde(default)] - pub size_bytes: Option, - #[serde(default)] - pub capabilities: Vec, - #[serde(default)] - pub description: Option, - pub provider: ProviderMetadata, - #[serde(default)] - pub metadata: HashMap, -} - -/// Unified request for streaming text generation across providers. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct GenerateRequest { - pub model: String, - #[serde(default)] - pub prompt: Option, - #[serde(default)] - pub context: Vec, - #[serde(default)] - pub parameters: HashMap, - #[serde(default)] - pub metadata: HashMap, -} - -impl GenerateRequest { - /// Helper for building a request from the minimum required fields. - pub fn new(model: impl Into) -> Self { - Self { - model: model.into(), - prompt: None, - context: Vec::new(), - parameters: HashMap::new(), - metadata: HashMap::new(), - } - } -} - -/// Streamed chunk of generation output from a model. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct GenerateChunk { - #[serde(default)] - pub text: Option, - #[serde(default)] - pub is_final: bool, - #[serde(default)] - pub metadata: HashMap, -} - -impl GenerateChunk { - /// Construct a new chunk with the provided text payload. - pub fn from_text(text: impl Into) -> Self { - Self { - text: Some(text.into()), - is_final: false, - metadata: HashMap::new(), - } - } - - /// Mark the chunk as the terminal item in a stream. - pub fn final_chunk() -> Self { - Self { - text: None, - is_final: true, - metadata: HashMap::new(), - } - } -} diff --git a/crates/owlen-core/src/providers/mod.rs b/crates/owlen-core/src/providers/mod.rs deleted file mode 100644 index b6a49fe..0000000 --- a/crates/owlen-core/src/providers/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Built-in LLM provider implementations. -//! -//! Each provider integration lives in its own module so that maintenance -//! stays focused and configuration remains clear. - -pub mod ollama; - -pub use ollama::OllamaProvider; diff --git a/crates/owlen-core/src/providers/ollama.rs b/crates/owlen-core/src/providers/ollama.rs deleted file mode 100644 index 6f206df..0000000 --- a/crates/owlen-core/src/providers/ollama.rs +++ /dev/null @@ -1,2825 +0,0 @@ -//! Ollama provider built on top of the `ollama-rs` crate. -use std::{ - collections::{HashMap, HashSet}, - convert::TryFrom, - env, fs, - net::{SocketAddr, TcpStream}, - pin::Pin, - process::Command, - sync::{Arc, OnceLock}, - time::{Duration, Instant, SystemTime}, -}; - -use anyhow::anyhow; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use futures::{Stream, StreamExt, future::BoxFuture, future::join_all}; -use log::{debug, warn}; -use ollama_rs::{ - Ollama, - error::OllamaError, - generation::tools::{ - ToolCall as OllamaToolCall, ToolCallFunction as OllamaToolCallFunction, - ToolInfo as OllamaToolInfo, - }, - generation::{ - chat::{ - ChatMessage as OllamaMessage, ChatMessageResponse as OllamaChatResponse, - MessageRole as OllamaRole, request::ChatMessageRequest as OllamaChatRequest, - }, - images::Image, - }, - headers::{AUTHORIZATION, HeaderMap, HeaderValue}, - models::{LocalModel, ModelInfo as OllamaModelInfo, ModelOptions}, -}; -use reqwest::{Client, StatusCode, Url}; -use serde::Deserialize; -use serde_json::{Map as JsonMap, Value, json}; -use tokio::{sync::RwLock, time::sleep}; - -#[cfg(test)] -use std::sync::{Mutex, MutexGuard}; -#[cfg(test)] -use tokio_test::block_on; -use uuid::Uuid; - -use crate::{ - Error, Result, - config::{ - DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA, DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, GeneralSettings, - LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_API_KEY_ENV, - OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, - }, - llm::{LlmProvider, ProviderConfig}, - mcp::McpToolDescriptor, - model::{DetailedModelInfo, ModelDetailsCache, ModelManager}, - provider::{ProviderError, ProviderErrorKind}, - types::{ - ChatParameters, ChatRequest, ChatResponse, Message, MessageAttachment, ModelInfo, Role, - TokenUsage, ToolCall, - }, -}; - -const DEFAULT_TIMEOUT_SECS: u64 = 120; -const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60; -pub(crate) const CLOUD_BASE_URL: &str = OLLAMA_CLOUD_BASE_URL; -const LOCAL_PROBE_TIMEOUT_MS: u64 = 200; -const LOCAL_PROBE_TARGETS: &[&str] = &["127.0.0.1:11434", "[::1]:11434"]; -const LOCAL_TAGS_TIMEOUT_STEPS_MS: [u64; 3] = [400, 800, 1_600]; -const LOCAL_TAGS_RETRY_DELAYS_MS: [u64; 2] = [150, 300]; -const HEALTHCHECK_TIMEOUT_MS: u64 = 1_000; - -static LEGACY_CLOUD_ENV_WARNING: OnceLock<()> = OnceLock::new(); - -fn warn_legacy_cloud_env(var_name: &str) { - if LEGACY_CLOUD_ENV_WARNING.set(()).is_ok() { - warn!( - "Using legacy Ollama Cloud API key environment variable `{var_name}`. \ - Prefer configuring OLLAMA_API_KEY; legacy names remain supported but may be removed." - ); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum OllamaMode { - Local, - Cloud, -} - -impl OllamaMode { - fn default_base_url(self) -> &'static str { - match self { - Self::Local => "http://localhost:11434", - Self::Cloud => CLOUD_BASE_URL, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ScopeAvailability { - Unknown, - Available, - Unavailable, -} - -impl ScopeAvailability { - fn as_str(self) -> &'static str { - match self { - ScopeAvailability::Unknown => "unknown", - ScopeAvailability::Available => "available", - ScopeAvailability::Unavailable => "unavailable", - } - } -} - -#[derive(Debug, Clone)] -struct ScopeSnapshot { - models: Vec, - fetched_at: Option, - availability: ScopeAvailability, - last_error: Option, - last_checked: Option, - last_success_at: Option, -} - -impl Default for ScopeSnapshot { - fn default() -> Self { - Self { - models: Vec::new(), - fetched_at: None, - availability: ScopeAvailability::Unknown, - last_error: None, - last_checked: None, - last_success_at: None, - } - } -} - -impl ScopeSnapshot { - fn is_stale(&self, ttl: Duration) -> bool { - match self.fetched_at { - Some(ts) => ts.elapsed() >= ttl, - None => !self.models.is_empty(), - } - } - - fn last_checked_age_secs(&self) -> Option { - self.last_checked.map(|instant| instant.elapsed().as_secs()) - } - - fn last_success_age_secs(&self) -> Option { - self.last_success_at - .map(|instant| instant.elapsed().as_secs()) - } -} - -#[derive(Clone)] -struct ScopeHandle { - client: Ollama, - http_client: Client, - base_url: String, -} - -impl ScopeHandle { - fn new(client: Ollama, http_client: Client, base_url: impl Into) -> Self { - Self { - client, - http_client, - base_url: base_url.into(), - } - } - - fn api_url(&self, endpoint: &str) -> String { - build_api_endpoint(&self.base_url, endpoint) - } -} - -#[derive(Debug, Deserialize)] -struct TagsResponse { - #[serde(default)] - models: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProviderVariant { - Local, - Cloud, -} - -impl ProviderVariant { - fn supports_local(self) -> bool { - matches!(self, ProviderVariant::Local) - } - - fn supports_cloud(self) -> bool { - matches!(self, ProviderVariant::Cloud) - } -} - -#[derive(Debug)] -struct OllamaOptions { - provider_name: String, - variant: ProviderVariant, - mode: OllamaMode, - base_url: String, - request_timeout: Duration, - model_cache_ttl: Duration, - api_key: Option, - cloud_endpoint: Option, -} - -impl OllamaOptions { - fn new( - provider_name: impl Into, - variant: ProviderVariant, - mode: OllamaMode, - base_url: impl Into, - ) -> Self { - Self { - provider_name: provider_name.into(), - variant, - mode, - base_url: base_url.into(), - request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), - model_cache_ttl: Duration::from_secs(DEFAULT_MODEL_CACHE_TTL_SECS), - api_key: None, - cloud_endpoint: None, - } - } - - fn with_general(mut self, general: &GeneralSettings) -> Self { - self.model_cache_ttl = general.model_cache_ttl(); - self - } -} - -/// Ollama provider implementation backed by `ollama-rs`. -#[derive(Debug)] -pub struct OllamaProvider { - provider_name: String, - variant: ProviderVariant, - mode: OllamaMode, - client: Ollama, - http_client: Client, - base_url: String, - request_timeout: Duration, - api_key: Option, - cloud_endpoint: Option, - model_manager: ModelManager, - model_details_cache: ModelDetailsCache, - model_cache_ttl: Duration, - scope_cache: Arc>>, -} - -fn configured_mode_from_extra(config: &ProviderConfig) -> Option { - config - .extra - .get(OLLAMA_MODE_KEY) - .and_then(|value| value.as_str()) - .and_then(|value| match value.trim().to_ascii_lowercase().as_str() { - "local" => Some(OllamaMode::Local), - "cloud" => Some(OllamaMode::Cloud), - _ => None, - }) -} - -fn is_explicit_local_base(base_url: Option<&str>) -> bool { - base_url - .and_then(|raw| Url::parse(raw).ok()) - .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase())) - .map(|host| host == "localhost" || host == "127.0.0.1" || host == "::1") - .unwrap_or(false) -} - -fn is_explicit_cloud_base(base_url: Option<&str>) -> bool { - base_url - .map(|raw| { - let trimmed = raw.trim_end_matches('/'); - trimmed == CLOUD_BASE_URL || trimmed.starts_with("https://ollama.com/") - }) - .unwrap_or(false) -} - -#[cfg(test)] -static PROBE_OVERRIDE: OnceLock>> = OnceLock::new(); - -#[cfg(test)] -static TAGS_OVERRIDE: OnceLock, Error>>>> = - OnceLock::new(); - -#[cfg(test)] -static TAGS_OVERRIDE_GATE: OnceLock> = OnceLock::new(); - -#[cfg(test)] -static PROBE_OVERRIDE_GATE: OnceLock> = OnceLock::new(); - -#[cfg(test)] -fn set_probe_override(value: Option) { - let guard = PROBE_OVERRIDE.get_or_init(|| Mutex::new(None)); - *guard.lock().expect("probe override mutex poisoned") = value; -} - -#[cfg(test)] -fn probe_override_value() -> Option { - PROBE_OVERRIDE - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("probe override mutex poisoned") - .to_owned() -} - -#[cfg(test)] -fn set_tags_override( - sequence: Vec, Error>>, -) -> TagsOverrideGuard { - let gate = TAGS_OVERRIDE_GATE - .get_or_init(|| Mutex::new(())) - .lock() - .expect("tags override gate mutex poisoned"); - - let store = TAGS_OVERRIDE.get_or_init(|| Mutex::new(Vec::new())); - { - let mut guard = store.lock().expect("tags override mutex poisoned"); - guard.clear(); - for item in sequence.into_iter().rev() { - guard.push(item); - } - } - TagsOverrideGuard { gate: Some(gate) } -} - -#[cfg(test)] -fn pop_tags_override() -> Option, Error>> { - TAGS_OVERRIDE - .get_or_init(|| Mutex::new(Vec::new())) - .lock() - .expect("tags override mutex poisoned") - .pop() -} - -#[cfg(test)] -struct TagsOverrideGuard { - gate: Option>, -} - -#[cfg(test)] -impl Drop for TagsOverrideGuard { - fn drop(&mut self) { - if let Some(store) = TAGS_OVERRIDE.get() { - let mut guard = store.lock().expect("tags override mutex poisoned"); - guard.clear(); - } - self.gate.take(); - } -} - -fn probe_default_local_daemon(timeout: Duration) -> bool { - #[cfg(test)] - { - if let Some(value) = probe_override_value() { - return value; - } - } - - for target in LOCAL_PROBE_TARGETS { - if let Ok(address) = target.parse::() && TcpStream::connect_timeout(&address, timeout).is_ok() { - return true; - } - } - false -} - -impl OllamaProvider { - /// Create a provider targeting an explicit base URL (local usage). - pub fn new(base_url: impl Into) -> Result { - let input = base_url.into(); - let normalized = - normalize_base_url(Some(&input), OllamaMode::Local).map_err(Error::Config)?; - Self::with_options(OllamaOptions::new( - "ollama_local", - ProviderVariant::Local, - OllamaMode::Local, - normalized, - )) - } - - /// Construct a provider from configuration settings. - pub fn from_config( - provider_id: &str, - config: &ProviderConfig, - general: Option<&GeneralSettings>, - ) -> Result { - let provider_type = config.provider_type.trim().to_ascii_lowercase(); - let register_name = { - let candidate = provider_id.trim(); - if candidate.is_empty() { - if provider_type.is_empty() { - "ollama".to_string() - } else { - provider_type.clone() - } - } else { - candidate.replace('-', "_") - } - }; - - let variant = if register_name == "ollama_cloud" || provider_type == "ollama_cloud" { - ProviderVariant::Cloud - } else { - ProviderVariant::Local - }; - - let mut api_key = resolve_api_key(config.api_key.clone()) - .or_else(|| resolve_api_key_env_hint(config.api_key_env.as_deref())) - .or_else(|| env_var_non_empty(OLLAMA_API_KEY_ENV)) - .or_else(|| { - warn_legacy_cloud_env(LEGACY_OLLAMA_CLOUD_API_KEY_ENV); - env_var_non_empty(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) - }) - .or_else(|| { - warn_legacy_cloud_env(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV); - env_var_non_empty(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) - }); - let api_key_present = api_key.is_some(); - - let configured_mode = configured_mode_from_extra(config); - let configured_mode_label = config - .extra - .get(OLLAMA_MODE_KEY) - .and_then(|value| value.as_str()) - .unwrap_or("auto"); - let base_url = config.base_url.as_deref(); - let base_is_local = is_explicit_local_base(base_url); - let base_is_cloud = is_explicit_cloud_base(base_url); - let _base_is_other = base_url.is_some() && !base_is_local && !base_is_cloud; - - let mut local_probe_result = None; - let cloud_endpoint = config - .extra - .get(OLLAMA_CLOUD_ENDPOINT_KEY) - .and_then(Value::as_str) - .map(normalize_cloud_endpoint) - .transpose() - .map_err(Error::Config)?; - - if matches!(variant, ProviderVariant::Local) && configured_mode.is_none() { - let probe = probe_default_local_daemon(Duration::from_millis(LOCAL_PROBE_TIMEOUT_MS)); - local_probe_result = Some(probe); - } - - let mode = match variant { - ProviderVariant::Local => OllamaMode::Local, - ProviderVariant::Cloud => OllamaMode::Cloud, - }; - - if matches!(variant, ProviderVariant::Cloud) && !api_key_present { - return Err(Error::Config( - "Ollama Cloud API key not configured. Set providers.ollama_cloud.api_key or export OLLAMA_API_KEY (legacy: OLLAMA_CLOUD_API_KEY / OWLEN_OLLAMA_CLOUD_API_KEY)." - .into(), - )); - } - - let base_candidate = match mode { - OllamaMode::Local => base_url, - OllamaMode::Cloud => base_url.or(Some(CLOUD_BASE_URL)), - }; - - let normalized_base_url = - normalize_base_url(base_candidate, mode).map_err(Error::Config)?; - - let mut options = OllamaOptions::new( - register_name.clone(), - variant, - mode, - normalized_base_url.clone(), - ); - options.cloud_endpoint = cloud_endpoint.clone(); - - if let Some(timeout) = config - .extra - .get("timeout_secs") - .and_then(|value| value.as_u64()) - { - options.request_timeout = Duration::from_secs(timeout.max(5)); - } - - if let Some(cache_ttl) = config - .extra - .get("model_cache_ttl_secs") - .and_then(|value| value.as_u64()) - { - options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5)); - } - - options.api_key = api_key.take(); - - if let Some(general) = general { - options = options.with_general(general); - } - - debug!( - "Resolved Ollama provider '{}': mode={:?}, base_url={}, configured_mode={}, api_key_present={}, local_probe={}", - register_name, - mode, - normalized_base_url, - configured_mode_label, - if options.api_key.is_some() { - "yes" - } else { - "no" - }, - match local_probe_result { - Some(true) => "success", - Some(false) => "failed", - None => "skipped", - } - ); - - Self::with_options(options) - } - - fn with_options(options: OllamaOptions) -> Result { - let OllamaOptions { - provider_name, - variant, - mode, - base_url, - request_timeout, - model_cache_ttl, - api_key, - cloud_endpoint, - } = options; - - let api_key_ref = api_key.as_deref(); - let (ollama_client, http_client) = - build_client_for_base(&base_url, request_timeout, api_key_ref)?; - - let scope_cache = { - let mut initial = HashMap::new(); - initial.insert(OllamaMode::Local, ScopeSnapshot::default()); - initial.insert(OllamaMode::Cloud, ScopeSnapshot::default()); - Arc::new(RwLock::new(initial)) - }; - - Ok(Self { - provider_name: provider_name.trim().to_ascii_lowercase(), - variant, - mode, - client: ollama_client, - http_client, - base_url: base_url.trim_end_matches('/').to_string(), - request_timeout, - api_key, - cloud_endpoint, - model_manager: ModelManager::new(model_cache_ttl), - model_details_cache: ModelDetailsCache::new(model_cache_ttl), - model_cache_ttl, - scope_cache, - }) - } - - fn api_url(&self, endpoint: &str) -> String { - build_api_endpoint(&self.base_url, endpoint) - } - - fn local_base_url() -> &'static str { - OllamaMode::Local.default_base_url() - } - - fn scope_key(scope: OllamaMode) -> &'static str { - match scope { - OllamaMode::Local => "local", - OllamaMode::Cloud => "cloud", - } - } - - fn supports_local_scope(&self) -> bool { - self.variant.supports_local() - } - - fn supports_cloud_scope(&self) -> bool { - self.variant.supports_cloud() - } - - fn build_local_client(&self) -> Result> { - if !self.supports_local_scope() { - return Ok(None); - } - - if matches!(self.mode, OllamaMode::Local) { - return Ok(Some(ScopeHandle::new( - self.client.clone(), - self.http_client.clone(), - self.base_url.clone(), - ))); - } - - let (client, http_client) = - build_client_for_base(Self::local_base_url(), self.request_timeout, None)?; - Ok(Some(ScopeHandle::new( - client, - http_client, - Self::local_base_url(), - ))) - } - - fn build_cloud_client(&self) -> Result> { - if !self.supports_cloud_scope() { - return Ok(None); - } - - if matches!(self.mode, OllamaMode::Cloud) { - return Ok(Some(ScopeHandle::new( - self.client.clone(), - self.http_client.clone(), - self.base_url.clone(), - ))); - } - - let api_key = match self.api_key.as_deref() { - Some(key) if !key.trim().is_empty() => key, - _ => return Ok(None), - }; - - let endpoint = self.cloud_endpoint.as_deref().unwrap_or(CLOUD_BASE_URL); - - let (client, http_client) = - build_client_for_base(endpoint, self.request_timeout, Some(api_key))?; - Ok(Some(ScopeHandle::new(client, http_client, endpoint))) - } - - async fn cached_scope_models(&self, scope: OllamaMode) -> Option> { - let cache = self.scope_cache.read().await; - cache.get(&scope).and_then(|entry| { - if entry.availability == ScopeAvailability::Unknown { - return None; - } - - if entry.models.is_empty() { - return None; - } - - if let Some(ts) = entry.fetched_at && ts.elapsed() < self.model_cache_ttl { - return Some(entry.models.clone()); - } - - // Fallback to last good models even if stale; UI will mark as degraded - Some(entry.models.clone()) - }) - } - - async fn update_scope_success(&self, scope: OllamaMode, models: &[ModelInfo]) { - let mut cache = self.scope_cache.write().await; - let entry = cache.entry(scope).or_default(); - let now = Instant::now(); - entry.models = models.to_vec(); - entry.fetched_at = Some(now); - entry.last_checked = Some(now); - entry.last_success_at = Some(now); - entry.availability = ScopeAvailability::Available; - entry.last_error = None; - } - - async fn mark_scope_failure(&self, scope: OllamaMode, message: String) { - let mut cache = self.scope_cache.write().await; - let entry = cache.entry(scope).or_default(); - entry.availability = ScopeAvailability::Unavailable; - entry.last_error = Some(message); - entry.last_checked = Some(Instant::now()); - } - - async fn annotate_scope_status(&self, models: &mut [ModelInfo]) { - if models.is_empty() { - return; - } - - let cache = self.scope_cache.read().await; - for (scope, snapshot) in cache.iter() { - if snapshot.availability == ScopeAvailability::Unknown { - continue; - } - let scope_key = Self::scope_key(*scope); - let capability = format!( - "scope-status:{}:{}", - scope_key, - snapshot.availability.as_str() - ); - - for model in models.iter_mut() { - if !model.capabilities.iter().any(|cap| cap == &capability) { - model.capabilities.push(capability.clone()); - } - } - - let stale = snapshot.is_stale(self.model_cache_ttl); - let stale_capability = format!( - "scope-status-stale:{}:{}", - scope_key, - if stale { "1" } else { "0" } - ); - for model in models.iter_mut() { - if !model - .capabilities - .iter() - .any(|cap| cap == &stale_capability) - { - model.capabilities.push(stale_capability.clone()); - } - } - - if let Some(age) = snapshot.last_checked_age_secs() { - let age_capability = format!("scope-status-age:{}:{}", scope_key, age); - for model in models.iter_mut() { - if !model.capabilities.iter().any(|cap| cap == &age_capability) { - model.capabilities.push(age_capability.clone()); - } - } - } - - if let Some(success_age) = snapshot.last_success_age_secs() { - let success_capability = - format!("scope-status-success-age:{}:{}", scope_key, success_age); - for model in models.iter_mut() { - if !model - .capabilities - .iter() - .any(|cap| cap == &success_capability) - { - model.capabilities.push(success_capability.clone()); - } - } - } - - if let Some(raw_reason) = snapshot.last_error.as_ref() { - let cleaned = raw_reason.replace('\n', " ").trim().to_string(); - if !cleaned.is_empty() { - let truncated: String = cleaned.chars().take(160).collect(); - let message_capability = - format!("scope-status-message:{}:{}", scope_key, truncated); - for model in models.iter_mut() { - if !model - .capabilities - .iter() - .any(|cap| cap == &message_capability) - { - model.capabilities.push(message_capability.clone()); - } - } - } - } - } - } - - /// Attempt to resolve detailed model information for the given model, using the local cache when possible. - pub async fn get_model_info(&self, model_name: &str) -> Result { - if let Some(info) = self.model_details_cache.get(model_name).await { - return Ok(info); - } - self.fetch_and_cache_model_info(model_name, None).await - } - - /// Force-refresh model information for the specified model. - pub async fn refresh_model_info(&self, model_name: &str) -> Result { - self.model_details_cache.invalidate(model_name).await; - self.fetch_and_cache_model_info(model_name, None).await - } - - /// Retrieve detailed information for all locally available models. - pub async fn get_all_models_info(&self) -> Result> { - let models = self - .client - .list_local_models() - .await - .map_err(|err| self.map_ollama_error("list models", err, None))?; - - let mut details = Vec::with_capacity(models.len()); - for local in &models { - match self - .fetch_and_cache_model_info(&local.name, Some(local)) - .await - { - Ok(info) => details.push(info), - Err(err) => warn!("Failed to gather model info for '{}': {}", local.name, err), - } - } - Ok(details) - } - - /// Return any cached model information without touching the Ollama daemon. - pub async fn cached_model_info(&self) -> Vec { - self.model_details_cache.cached().await - } - - /// Remove a single model's cached information. - pub async fn invalidate_model_info(&self, model_name: &str) { - self.model_details_cache.invalidate(model_name).await; - } - - /// Clear the entire model information cache. - pub async fn clear_model_info_cache(&self) { - self.model_details_cache.invalidate_all().await; - } - - async fn fetch_and_cache_model_info( - &self, - model_name: &str, - local: Option<&LocalModel>, - ) -> Result { - let detail = self - .client - .show_model_info(model_name.to_string()) - .await - .map_err(|err| self.map_ollama_error("show_model_info", err, Some(model_name)))?; - - let local_owned = if let Some(local) = local { - Some(local.clone()) - } else { - let models = self - .client - .list_local_models() - .await - .map_err(|err| self.map_ollama_error("list models", err, None))?; - models.into_iter().find(|m| m.name == model_name) - }; - - let detailed = - Self::convert_detailed_model_info(self.mode, model_name, local_owned.as_ref(), &detail); - self.model_details_cache.insert(detailed.clone()).await; - Ok(detailed) - } - - fn prepare_chat_request( - &self, - model: String, - messages: Vec, - parameters: ChatParameters, - tools: Option>, - ) -> Result<(String, OllamaChatRequest)> { - if self.mode == OllamaMode::Cloud && !model.contains("-cloud") { - warn!( - "Model '{}' does not use the '-cloud' suffix. Cloud-only models may fail to load.", - model - ); - } - - let converted_messages = messages.into_iter().map(convert_message).collect(); - let mut request = OllamaChatRequest::new(model.clone(), converted_messages); - - if let Some(options) = build_model_options(¶meters)? { - request.options = Some(options); - } - - if let Some(tool_descriptors) = tools.as_ref() { - let tool_infos = convert_tool_descriptors(tool_descriptors)?; - if !tool_infos.is_empty() { - request.tools = tool_infos; - } - } - - Ok((model, request)) - } - - async fn fetch_models(&self) -> Result> { - let mut combined = Vec::new(); - let mut seen: HashSet = HashSet::new(); - let mut errors: Vec = Vec::new(); - - if let Some(local_handle) = self.build_local_client()? { - match self - .fetch_models_for_scope(OllamaMode::Local, local_handle) - .await - { - Ok(models) => { - for model in models { - let key = format!("local::{}", model.id); - if seen.insert(key) { - combined.push(model); - } - } - } - Err(err) => errors.push(err), - } - } - - if let Some(cloud_handle) = self.build_cloud_client()? { - match self - .fetch_models_for_scope(OllamaMode::Cloud, cloud_handle) - .await - { - Ok(models) => { - for model in models { - let key = format!("cloud::{}", model.id); - if seen.insert(key) { - combined.push(model); - } - } - } - Err(err) => errors.push(err), - } - } - - if combined.is_empty() && let Some(err) = errors.pop() { - return Err(err); - } - - self.annotate_scope_status(&mut combined).await; - combined.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - Ok(combined) - } - - async fn fetch_models_for_scope( - &self, - scope: OllamaMode, - handle: ScopeHandle, - ) -> Result> { - let tags_result = self.fetch_scope_tags_with_retry(scope, &handle).await; - - let models = match tags_result { - Ok(models) => models, - Err(err) => { - let original_detail = err.to_string(); - let (error, banner) = self.decorate_scope_error(scope, &handle.base_url, err); - if banner != original_detail { - debug!( - "Model list for {:?} at {} failed: {}", - scope, handle.base_url, original_detail - ); - } - self.mark_scope_failure(scope, banner.clone()).await; - if let Some(cached) = self.cached_scope_models(scope).await { - return Ok(cached); - } - return Err(error); - } - }; - - let cache = self.model_details_cache.clone(); - let client = handle.client.clone(); - let fetched = join_all(models.into_iter().map(|local| { - let client = client.clone(); - let cache = cache.clone(); - async move { - let name = local.name.clone(); - let detail = match client.show_model_info(name.clone()).await { - Ok(info) => { - let detailed = OllamaProvider::convert_detailed_model_info( - scope, - &name, - Some(&local), - &info, - ); - cache.insert(detailed).await; - Some(info) - } - Err(err) => { - debug!("Failed to fetch Ollama model info for '{name}': {err}"); - None - } - }; - (local, detail) - } - })) - .await; - - let converted: Vec = fetched - .into_iter() - .map(|(local, detail)| self.convert_model(scope, local, detail)) - .collect(); - - self.update_scope_success(scope, &converted).await; - Ok(converted) - } - - async fn fetch_scope_tags_with_retry( - &self, - scope: OllamaMode, - handle: &ScopeHandle, - ) -> Result> { - let attempts = if matches!(scope, OllamaMode::Local) { - LOCAL_TAGS_TIMEOUT_STEPS_MS.len() - } else { - 1 - }; - - let mut last_error: Option = None; - - for attempt in 0..attempts { - match self.fetch_scope_tags_once(scope, handle, attempt).await { - Ok(models) => return Ok(models), - Err(err) => { - let should_retry = matches!(scope, OllamaMode::Local) - && attempt + 1 < attempts - && matches!(err, Error::Timeout(_) | Error::Network(_)); - - if should_retry { - debug!( - "Retrying Ollama model list for {:?} (attempt {}): {}", - scope, - attempt + 1, - err - ); - last_error = Some(err); - sleep(self.tags_retry_delay(attempt)).await; - continue; - } - return Err(err); - } - } - } - - Err(last_error - .unwrap_or_else(|| Error::Unknown("Ollama model list retries exhausted".to_string()))) - } - - async fn fetch_scope_tags_once( - &self, - scope: OllamaMode, - handle: &ScopeHandle, - attempt: usize, - ) -> Result> { - #[cfg(test)] - if let Some(result) = pop_tags_override() { - return result; - } - - if matches!(scope, OllamaMode::Local) { - match self.list_local_models_via_cli() { - Ok(models) => return Ok(models), - Err(err) => { - debug!("`ollama ls` failed ({}); falling back to HTTP listing", err); - } - } - } - - let url = handle.api_url("tags"); - let response = handle - .http_client - .get(&url) - .timeout(self.tags_request_timeout(scope, attempt)) - .send() - .await - .map_err(|err| map_reqwest_error("list models", err))?; - - if !response.status().is_success() { - let status = response.status(); - let detail = response.text().await.unwrap_or_else(|err| err.to_string()); - return Err(self.map_http_failure("list models", status, detail, None)); - } - - let bytes = response - .bytes() - .await - .map_err(|err| map_reqwest_error("list models", err))?; - let parsed: TagsResponse = serde_json::from_slice(&bytes)?; - Ok(parsed.models) - } - - fn tags_request_timeout(&self, scope: OllamaMode, attempt: usize) -> Duration { - if matches!(scope, OllamaMode::Local) { - let idx = attempt.min(LOCAL_TAGS_TIMEOUT_STEPS_MS.len() - 1); - Duration::from_millis(LOCAL_TAGS_TIMEOUT_STEPS_MS[idx]) - } else { - self.request_timeout - } - } - - fn tags_retry_delay(&self, attempt: usize) -> Duration { - let idx = attempt.min(LOCAL_TAGS_RETRY_DELAYS_MS.len() - 1); - Duration::from_millis(LOCAL_TAGS_RETRY_DELAYS_MS[idx]) - } - - fn list_local_models_via_cli(&self) -> Result> { - let output = Command::new("ollama") - .arg("ls") - .output() - .map_err(|err| { - Error::Provider(anyhow!( - "Failed to execute `ollama ls`: {err}. Ensure the Ollama CLI is installed and accessible in PATH." - )) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(Error::Provider(anyhow!( - "`ollama ls` exited with status {}: {}", - output.status, - stderr.trim() - ))); - } - - let stdout = String::from_utf8(output.stdout).map_err(|err| { - Error::Provider(anyhow!("`ollama ls` returned non-UTF8 output: {err}")) - })?; - - let mut models = Vec::new(); - for line in stdout.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let lowercase = trimmed.to_ascii_lowercase(); - if lowercase.starts_with("name") { - continue; - } - - let mut parts = trimmed.split_whitespace(); - let Some(name) = parts.next() else { - continue; - }; - let metadata_start = trimmed[name.len()..].trim(); - - models.push(LocalModel { - name: name.to_string(), - modified_at: metadata_start.to_string(), - size: 0, - }); - } - - Ok(models) - } - - fn decorate_scope_error( - &self, - scope: OllamaMode, - base_url: &str, - err: Error, - ) -> (Error, String) { - if matches!(scope, OllamaMode::Local) { - match err { - Error::Timeout(_) => { - let message = format_local_unreachable_message(base_url); - (Error::Timeout(message.clone()), message) - } - Error::Network(original) => { - if is_connectivity_error(&original) { - let message = format_local_unreachable_message(base_url); - (Error::Network(message.clone()), message) - } else { - let message = original.clone(); - (Error::Network(original), message) - } - } - other => { - let message = other.to_string(); - (other, message) - } - } - } else { - let message = err.to_string(); - (err, message) - } - } - - fn convert_detailed_model_info( - mode: OllamaMode, - model_name: &str, - local: Option<&LocalModel>, - detail: &OllamaModelInfo, - ) -> DetailedModelInfo { - let map = &detail.model_info; - - let architecture = - pick_first_string(map, &["architecture", "model_format", "model_type", "arch"]); - - let parameters = non_empty(detail.parameters.clone()) - .or_else(|| pick_first_string(map, &["parameters"])); - - let parameter_size = pick_first_string(map, &["parameter_size"]); - - let context_length = pick_first_u64(map, &["context_length", "num_ctx", "max_context"]); - let embedding_length = pick_first_u64(map, &["embedding_length"]); - - let quantization = - pick_first_string(map, &["quantization_level", "quantization", "quantize"]); - - let family = pick_first_string(map, &["family", "model_family"]); - let mut families = pick_string_list(map, &["families", "model_families"]); - - if families.is_empty() { - families.extend(family.clone()); - } - - let system = pick_first_string(map, &["system"]); - - let mut modified_at = local - .and_then(|entry| non_empty(entry.modified_at.clone())) - .or_else(|| pick_first_string(map, &["modified_at", "created_at"])); - - if modified_at.is_none() && mode == OllamaMode::Cloud { - modified_at = pick_first_string(map, &["updated_at"]); - } - - let size = local - .and_then(|entry| { - if entry.size > 0 { - Some(entry.size) - } else { - None - } - }) - .or_else(|| pick_first_u64(map, &["size", "model_size", "download_size"])); - - let digest = pick_first_string(map, &["digest", "sha256", "checksum"]); - - let mut info = DetailedModelInfo { - name: model_name.to_string(), - architecture, - parameters, - context_length, - embedding_length, - quantization, - family, - families, - parameter_size, - template: non_empty(detail.template.clone()), - system, - license: non_empty(detail.license.clone()), - modelfile: non_empty(detail.modelfile.clone()), - modified_at, - size, - digest, - }; - - if info.parameter_size.is_none() { - info.parameter_size = info.parameters.clone(); - } - - info.with_normalised_strings() - } - - fn convert_model( - &self, - scope: OllamaMode, - model: LocalModel, - detail: Option, - ) -> ModelInfo { - let scope_tag = match scope { - OllamaMode::Local => "local", - OllamaMode::Cloud => "cloud", - }; - - let name = model.name; - let mut capabilities: Vec = detail - .as_ref() - .map(|info| { - info.capabilities - .iter() - .map(|cap| cap.to_ascii_lowercase()) - .collect() - }) - .unwrap_or_default(); - - push_capability(&mut capabilities, "chat"); - - for heuristic in heuristic_capabilities(&name) { - push_capability(&mut capabilities, &heuristic); - } - - push_capability(&mut capabilities, &format!("scope:{scope_tag}")); - - let description = build_model_description(scope_tag, detail.as_ref()); - - let context_window = detail.as_ref().and_then(|info| { - pick_first_u64( - &info.model_info, - &["context_length", "num_ctx", "max_context"], - ) - .and_then(|raw| u32::try_from(raw).ok()) - }); - - let supports_tools = model_supports_tools(&name, &capabilities, detail.as_ref()); - - ModelInfo { - id: name.clone(), - name, - description: Some(description), - provider: self.provider_name.clone(), - context_window, - capabilities, - supports_tools, - } - } - - fn convert_ollama_response(response: OllamaChatResponse, streaming: bool) -> ChatResponse { - let OllamaChatResponse { - model, - created_at, - message, - done, - final_data, - } = response; - - let usage = final_data.as_ref().map(|data| { - let prompt = clamp_to_u32(data.prompt_eval_count); - let completion = clamp_to_u32(data.eval_count); - TokenUsage { - prompt_tokens: prompt, - completion_tokens: completion, - total_tokens: prompt.saturating_add(completion), - } - }); - - let mut message = convert_ollama_message(message); - - let mut provider_meta = JsonMap::new(); - provider_meta.insert("model".into(), Value::String(model)); - provider_meta.insert("created_at".into(), Value::String(created_at)); - - if let Some(ref final_block) = final_data && let Ok(value) = serde_json::to_value(final_block) { - provider_meta.insert("final_data".into(), value); - } - - message - .metadata - .insert("ollama".into(), Value::Object(provider_meta)); - - ChatResponse { - message, - usage, - is_streaming: streaming, - is_final: if streaming { done } else { true }, - } - } - - fn provider_failure( - &self, - kind: ProviderErrorKind, - message: impl Into, - detail: Option, - ) -> Error { - let error = ProviderError::new(kind, message).with_provider(self.provider_name.clone()); - let error = if let Some(detail) = detail { - error.with_detail(detail) - } else { - error - }; - Error::ProviderFailure(error) - } - - fn map_ollama_error(&self, action: &str, err: OllamaError, model: Option<&str>) -> Error { - match err { - OllamaError::ReqwestError(request_err) => { - if let Some(status) = request_err.status() { - self.map_http_failure(action, status, request_err.to_string(), model) - } else if request_err.is_timeout() { - self.provider_failure( - ProviderErrorKind::Timeout, - format!("Ollama {action} timed out"), - Some(request_err.to_string()), - ) - } else if request_err.is_connect() || request_err.is_request() { - self.provider_failure( - ProviderErrorKind::Network, - format!("Ollama {action} request failed"), - Some(request_err.to_string()), - ) - } else { - Error::Provider(anyhow!(request_err)) - } - } - OllamaError::InternalError(internal) => self.provider_failure( - ProviderErrorKind::Protocol, - format!("Ollama {action} internal error"), - Some(internal.message), - ), - OllamaError::Other(message) => { - let parsed_error = serde_json::from_str::(&message) - .ok() - .and_then(|value| { - value - .get("error") - .and_then(Value::as_str) - .map(|err| err.trim().to_string()) - }) - .map(|err| err.to_ascii_lowercase()); - - if let Some(err) = parsed_error.as_deref() { - if err.contains("too many") || err.contains("rate limit") { - return self.provider_failure( - ProviderErrorKind::RateLimited, - format!("Ollama {action} request rate limited"), - Some(message), - ); - } - - if err.contains("unauthorized") || err.contains("invalid api key") { - return self.provider_failure( - ProviderErrorKind::Unauthorized, - format!("Ollama {action} rejected the request (unauthorized). Check your API key and account permissions."), - Some(message), - ); - } - } - - self.provider_failure( - ProviderErrorKind::Unknown, - format!("Ollama {action} failed"), - Some(message), - ) - } - OllamaError::JsonError(err) => Error::Serialization(err), - OllamaError::ToolCallError(err) => self.provider_failure( - ProviderErrorKind::Protocol, - format!("Ollama {action} tool call failed"), - Some(err.to_string()), - ), - } - } - - fn map_http_failure( - &self, - action: &str, - status: StatusCode, - detail: String, - model: Option<&str>, - ) -> Error { - match status { - StatusCode::NOT_FOUND => { - if let Some(model) = model { - Error::InvalidInput(format!( - "Model '{model}' was not found at {}. Verify the name or pull it with `ollama pull {model}`.", - self.base_url - )) - } else { - Error::InvalidInput(format!( - "{action} returned 404 from {}: {detail}", - self.base_url - )) - } - } - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => self.provider_failure( - ProviderErrorKind::Unauthorized, - format!( - "Ollama rejected the request ({status}). Check your API key and account permissions." - ), - Some(detail), - ), - StatusCode::TOO_MANY_REQUESTS => self.provider_failure( - ProviderErrorKind::RateLimited, - format!("Ollama {action} request rate limited"), - Some(detail), - ), - StatusCode::BAD_REQUEST => { - Error::InvalidInput(format!("{action} rejected by Ollama ({status}): {detail}")) - } - StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT => self.provider_failure( - ProviderErrorKind::Timeout, - format!("Ollama {action} timed out ({status}). The model may still be loading."), - Some(detail), - ), - status if status.is_server_error() => self.provider_failure( - ProviderErrorKind::Unavailable, - format!("Ollama {action} request failed ({status}). Try again later."), - Some(detail), - ), - status if status.is_client_error() => self.provider_failure( - ProviderErrorKind::InvalidRequest, - format!("Ollama {action} rejected the request ({status})."), - Some(detail), - ), - _ => self.provider_failure( - ProviderErrorKind::Unknown, - format!("Ollama {action} failed ({status})."), - Some(detail), - ), - } - } -} - -impl LlmProvider for OllamaProvider { - type Stream = Pin> + Send>>; - type ListModelsFuture<'a> - = BoxFuture<'a, Result>> - where - Self: 'a; - type SendPromptFuture<'a> - = BoxFuture<'a, Result> - where - Self: 'a; - type StreamPromptFuture<'a> - = BoxFuture<'a, Result> - where - Self: 'a; - type HealthCheckFuture<'a> - = BoxFuture<'a, Result<()>> - where - Self: 'a; - - fn name(&self) -> &str { - &self.provider_name - } - - fn list_models(&self) -> Self::ListModelsFuture<'_> { - Box::pin(async move { - self.model_manager - .get_or_refresh(false, || async { self.fetch_models().await }) - .await - }) - } - - fn send_prompt(&self, request: ChatRequest) -> Self::SendPromptFuture<'_> { - Box::pin(async move { - let ChatRequest { - model, - messages, - parameters, - tools, - } = request; - - let (model_id, ollama_request) = - self.prepare_chat_request(model, messages, parameters, tools)?; - - let response = self - .client - .send_chat_messages(ollama_request) - .await - .map_err(|err| self.map_ollama_error("chat", err, Some(&model_id)))?; - - Ok(Self::convert_ollama_response(response, false)) - }) - } - - fn stream_prompt(&self, request: ChatRequest) -> Self::StreamPromptFuture<'_> { - Box::pin(async move { - let ChatRequest { - model, - messages, - parameters, - tools, - } = request; - - let (model_id, ollama_request) = - self.prepare_chat_request(model, messages, parameters, tools)?; - - let stream = self - .client - .send_chat_messages_stream(ollama_request) - .await - .map_err(|err| self.map_ollama_error("chat_stream", err, Some(&model_id)))?; - - let mapped = stream.map(|item| match item { - Ok(chunk) => Ok(Self::convert_ollama_response(chunk, true)), - Err(_) => Err(Error::Provider(anyhow!( - "Ollama returned a malformed streaming chunk" - ))), - }); - - Ok(Box::pin(mapped) as Self::Stream) - }) - } - - fn health_check(&self) -> Self::HealthCheckFuture<'_> { - Box::pin(async move { - let url = self.api_url("tags?limit=1"); - let response = self - .http_client - .get(&url) - .timeout(Duration::from_millis(HEALTHCHECK_TIMEOUT_MS)) - .send() - .await - .map_err(|err| map_reqwest_error("health check", err))?; - - if response.status().is_success() { - return Ok(()); - } - - let status = response.status(); - let detail = response.text().await.unwrap_or_else(|err| err.to_string()); - Err(self.map_http_failure("health check", status, detail, None)) - }) - } - - fn config_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "base_url": { - "type": "string", - "description": "Base URL for the Ollama API (ignored when api_key is provided)", - "default": self.mode.default_base_url() - }, - "timeout_secs": { - "type": "integer", - "description": "HTTP request timeout in seconds", - "minimum": 5, - "default": DEFAULT_TIMEOUT_SECS - }, - "model_cache_ttl_secs": { - "type": "integer", - "description": "Seconds to cache model listings", - "minimum": 5, - "default": DEFAULT_MODEL_CACHE_TTL_SECS - }, - "hourly_quota_tokens": { - "type": "integer", - "description": "Soft hourly token quota used for UI alerts", - "minimum": 0, - "default": DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA - }, - "weekly_quota_tokens": { - "type": "integer", - "description": "Soft weekly token quota used for UI alerts", - "minimum": 0, - "default": DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA - } - } - }) - } -} - -fn build_model_options(parameters: &ChatParameters) -> Result> { - let mut options = JsonMap::new(); - - for (key, value) in ¶meters.extra { - options.insert(key.clone(), value.clone()); - } - - if let Some(temperature) = parameters.temperature { - options.insert("temperature".to_string(), json!(temperature)); - } - - if let Some(max_tokens) = parameters.max_tokens { - let capped = i32::try_from(max_tokens).unwrap_or(i32::MAX); - options.insert("num_predict".to_string(), json!(capped)); - } - - if options.is_empty() { - return Ok(None); - } - - serde_json::from_value(Value::Object(options)) - .map(Some) - .map_err(|err| Error::Config(format!("Invalid Ollama options: {err}"))) -} - -fn convert_tool_descriptors(descriptors: &[McpToolDescriptor]) -> Result> { - descriptors - .iter() - .map(|descriptor| { - let payload = json!({ - "type": "Function", - "function": { - "name": descriptor.name, - "description": descriptor.description, - "parameters": descriptor.input_schema - } - }); - - serde_json::from_value(payload).map_err(|err| { - Error::Config(format!( - "Invalid tool schema for '{}': {err}", - descriptor.name - )) - }) - }) - .collect() -} - -fn convert_message(message: Message) -> OllamaMessage { - let Message { - role, - content, - metadata, - tool_calls, - attachments, - .. - } = message; - - let role = match role { - Role::User => OllamaRole::User, - Role::Assistant => OllamaRole::Assistant, - Role::System => OllamaRole::System, - Role::Tool => OllamaRole::Tool, - }; - - let tool_calls = tool_calls - .unwrap_or_default() - .into_iter() - .map(|tool_call| OllamaToolCall { - function: OllamaToolCallFunction { - name: tool_call.name, - arguments: tool_call.arguments, - }, - }) - .collect(); - - let thinking = metadata - .get("thinking") - .and_then(|value| value.as_str().map(|s| s.to_owned())); - - let images: Vec = attachments - .into_iter() - .filter_map(|attachment| { - if !attachment.is_image() { - return None; - } - if let Some(data) = attachment.data_base64 { - return Some(Image::from_base64(data)); - } - if let Some(path) = attachment.source_path { - match fs::read(&path) { - Ok(bytes) => { - let encoded = BASE64_STANDARD.encode(bytes); - return Some(Image::from_base64(encoded)); - } - Err(err) => { - warn!( - "Failed to read attachment '{}' for image conversion: {}", - path.display(), - err - ); - } - } - } - None - }) - .collect(); - - OllamaMessage { - role, - content, - tool_calls, - images: if images.is_empty() { - None - } else { - Some(images) - }, - thinking, - } -} - -fn convert_ollama_message(message: OllamaMessage) -> Message { - let role = match message.role { - OllamaRole::Assistant => Role::Assistant, - OllamaRole::System => Role::System, - OllamaRole::Tool => Role::Tool, - OllamaRole::User => Role::User, - }; - - let tool_calls = if message.tool_calls.is_empty() { - None - } else { - Some( - message - .tool_calls - .into_iter() - .enumerate() - .map(|(idx, tool_call)| ToolCall { - id: format!("tool-call-{idx}"), - name: tool_call.function.name, - arguments: tool_call.function.arguments, - }) - .collect::>(), - ) - }; - - let mut metadata = HashMap::new(); - if let Some(thinking) = message.thinking { - metadata.insert("thinking".to_string(), Value::String(thinking)); - } - - let attachments = message - .images - .unwrap_or_default() - .into_iter() - .enumerate() - .filter_map(|(idx, image)| { - let data = image.to_base64(); - if data.is_empty() { - return None; - } - let size_bytes = (data.len() as u64).saturating_mul(3).saturating_div(4); - let name = format!("image-{}.png", idx + 1); - Some( - MessageAttachment::from_base64( - name, - "image/png", - data.to_string(), - Some(size_bytes), - ) - .with_description(format!("Generated image {}", idx + 1)), - ) - }) - .collect(); - - Message { - id: Uuid::new_v4(), - role, - content: message.content, - metadata, - timestamp: SystemTime::now(), - tool_calls, - attachments, - } -} - -fn clamp_to_u32(value: u64) -> u32 { - u32::try_from(value).unwrap_or(u32::MAX) -} - -fn push_capability(capabilities: &mut Vec, capability: &str) { - let candidate = capability.to_ascii_lowercase(); - if !capabilities - .iter() - .any(|existing| existing.eq_ignore_ascii_case(&candidate)) - { - capabilities.push(candidate); - } -} - -fn heuristic_capabilities(name: &str) -> Vec { - let lowercase = name.to_ascii_lowercase(); - let mut detected = Vec::new(); - - if lowercase.contains("vision") - || lowercase.contains("multimodal") - || lowercase.contains("image") - { - detected.push("vision".to_string()); - } - - if lowercase.contains("think") - || lowercase.contains("reason") - || lowercase.contains("deepseek-r1") - || lowercase.contains("r1") - { - detected.push("thinking".to_string()); - } - - if lowercase.contains("audio") || lowercase.contains("speech") || lowercase.contains("voice") { - detected.push("audio".to_string()); - } - - detected -} - -fn capability_implies_tools(label: &str) -> bool { - let normalized = label.to_ascii_lowercase(); - normalized.contains("tool") - || normalized.contains("function_call") - || normalized.contains("function-call") - || normalized.contains("tool_call") -} - -fn model_supports_tools( - name: &str, - capabilities: &[String], - detail: Option<&OllamaModelInfo>, -) -> bool { - if let Some(info) = detail && info - .capabilities - .iter() - .any(|capability| capability_implies_tools(capability)) - { - return true; - } - - if capabilities - .iter() - .any(|capability| capability_implies_tools(capability)) - { - return true; - } - - let lowered = name.to_ascii_lowercase(); - ["functioncall", "function-call", "function_call", "tool"] - .iter() - .any(|needle| lowered.contains(needle)) -} - -fn build_model_description(scope: &str, detail: Option<&OllamaModelInfo>) -> String { - if let Some(info) = detail { - let mut parts = Vec::new(); - - if let Some(family) = info - .model_info - .get("family") - .and_then(|value| value.as_str()) - { - parts.push(family.to_string()); - } - - if let Some(parameter_size) = info - .model_info - .get("parameter_size") - .and_then(|value| value.as_str()) - { - parts.push(parameter_size.to_string()); - } - - if let Some(variant) = info - .model_info - .get("variant") - .and_then(|value| value.as_str()) - { - parts.push(variant.to_string()); - } - - if !parts.is_empty() { - return format!("Ollama ({scope}) – {}", parts.join(" · ")); - } - } - - format!("Ollama ({scope}) model") -} - -fn non_empty(value: String) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(value) - } -} - -fn pick_first_string(map: &JsonMap, keys: &[&str]) -> Option { - keys.iter() - .filter_map(|key| map.get(*key)) - .find_map(value_to_string) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -fn pick_first_u64(map: &JsonMap, keys: &[&str]) -> Option { - keys.iter() - .filter_map(|key| map.get(*key)) - .find_map(value_to_u64) -} - -fn pick_string_list(map: &JsonMap, keys: &[&str]) -> Vec { - for key in keys { - if let Some(value) = map.get(*key) { - match value { - Value::Array(items) => { - let collected: Vec = items - .iter() - .filter_map(value_to_string) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if !collected.is_empty() { - return collected; - } - } - Value::String(text) => { - let collected: Vec = text - .split(',') - .map(|part| part.trim()) - .filter(|part| !part.is_empty()) - .map(|part| part.to_string()) - .collect(); - if !collected.is_empty() { - return collected; - } - } - _ => {} - } - } - } - Vec::new() -} - -fn value_to_string(value: &Value) -> Option { - match value { - Value::String(text) => Some(text.clone()), - Value::Number(num) => Some(num.to_string()), - Value::Bool(flag) => Some(flag.to_string()), - _ => None, - } -} - -fn value_to_u64(value: &Value) -> Option { - match value { - Value::Number(num) => { - if let Some(v) = num.as_u64() { - Some(v) - } else if let Some(v) = num.as_i64() { - v.try_into().ok() - } else if let Some(v) = num.as_f64() { - if v >= 0.0 { Some(v as u64) } else { None } - } else { - None - } - } - Value::String(text) => text.trim().parse::().ok(), - _ => None, - } -} - -fn format_local_unreachable_message(base_url: &str) -> String { - let display = display_host_port(base_url); - format!( - "Ollama not reachable on {display}. Start the Ollama daemon (`ollama serve`) and try again." - ) -} - -fn display_host_port(base_url: &str) -> String { - Url::parse(base_url) - .ok() - .and_then(|url| { - url.host_str().map(|host| { - if let Some(port) = url.port() { - format!("{host}:{port}") - } else { - host.to_string() - } - }) - }) - .unwrap_or_else(|| base_url.to_string()) -} - -fn is_connectivity_error(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - const CONNECTIVITY_MARKERS: &[&str] = &[ - "connection refused", - "failed to connect", - "connect timeout", - "timed out while contacting", - "dns error", - "failed to lookup address", - "no route to host", - "host unreachable", - ]; - - CONNECTIVITY_MARKERS - .iter() - .any(|marker| lower.contains(marker)) -} - -fn env_var_non_empty(name: &str) -> Option { - env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn resolve_api_key_env_hint(env_var: Option<&str>) -> Option { - let var = env_var?.trim(); - if var.is_empty() { - return None; - } - - if var.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) - || var.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) - { - warn_legacy_cloud_env(var); - } - - env_var_non_empty(var) -} - -fn resolve_api_key(configured: Option) -> Option { - let raw = configured?.trim().to_string(); - if raw.is_empty() { - return None; - } - - if let Some(variable) = raw - .strip_prefix("${") - .and_then(|value| value.strip_suffix('}')) - .or_else(|| raw.strip_prefix('$')) - { - let var_name = variable.trim(); - if var_name.is_empty() { - return None; - } - return env_var_non_empty(var_name); - } - - Some(raw) -} - -fn map_reqwest_error(action: &str, err: reqwest::Error) -> Error { - if err.is_timeout() { - Error::Timeout(format!("Ollama {action} request timed out: {err}")) - } else { - Error::Network(format!("Ollama {action} request failed: {err}")) - } -} - -pub(crate) fn normalize_cloud_base_url(input: Option<&str>) -> std::result::Result { - normalize_base_url(input, OllamaMode::Cloud) -} - -fn normalize_base_url( - input: Option<&str>, - mode_hint: OllamaMode, -) -> std::result::Result { - let mut candidate = input - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) - .unwrap_or_else(|| mode_hint.default_base_url().to_string()); - - if !candidate.starts_with("http://") && !candidate.starts_with("https://") { - candidate = format!("https://{candidate}"); - } - - let mut url = - Url::parse(&candidate).map_err(|err| format!("Invalid Ollama URL '{candidate}': {err}"))?; - - if url.cannot_be_a_base() { - return Err(format!("URL '{candidate}' cannot be used as a base URL")); - } - - if mode_hint == OllamaMode::Cloud && url.scheme() != "https" && std::env::var("OWLEN_ALLOW_INSECURE_CLOUD").is_err() { - return Err("Ollama Cloud requires https:// base URLs".to_string()); - } - - let path = url.path().trim_end_matches('/'); - match path { - "" | "/" => {} - "/api" | "/v1" => { - url.set_path("/"); - } - _ => { - return Err("Ollama base URLs must not include additional path segments".to_string()); - } - } - - if mode_hint == OllamaMode::Cloud && let Some(host) = url.host_str() && host.eq_ignore_ascii_case("api.ollama.com") { - url.set_host(Some("ollama.com")) - .map_err(|err| format!("Failed to normalise Ollama Cloud host: {err}"))?; - } - - url.set_query(None); - url.set_fragment(None); - - Ok(url.to_string().trim_end_matches('/').to_string()) -} - -fn normalize_cloud_endpoint(input: &str) -> std::result::Result { - normalize_base_url(Some(input), OllamaMode::Cloud) -} - -fn build_api_endpoint(base_url: &str, endpoint: &str) -> String { - let trimmed_base = base_url.trim_end_matches('/'); - let trimmed_endpoint = endpoint.trim_start_matches('/'); - - if trimmed_base.ends_with("/api") { - format!("{trimmed_base}/{trimmed_endpoint}") - } else { - format!("{trimmed_base}/api/{trimmed_endpoint}") - } -} - -fn build_client_for_base( - base_url: &str, - timeout: Duration, - api_key: Option<&str>, -) -> Result<(Ollama, Client)> { - let url = Url::parse(base_url) - .map_err(|err| Error::Config(format!("Invalid Ollama base URL '{base_url}': {err}")))?; - - let mut headers = HeaderMap::new(); - if let Some(key) = api_key { - let value = HeaderValue::from_str(&format!("Bearer {key}")) - .map_err(|_| Error::Config("OLLAMA API key contains invalid characters".to_string()))?; - headers.insert(AUTHORIZATION, value); - } - - let mut client_builder = Client::builder().timeout(timeout); - if !headers.is_empty() { - client_builder = client_builder.default_headers(headers.clone()); - } - - let http_client = client_builder.build().map_err(|err| { - Error::Config(format!( - "Failed to build HTTP client for '{base_url}': {err}" - )) - })?; - - let port = url.port_or_known_default().ok_or_else(|| { - Error::Config(format!("Unable to determine port for Ollama URL '{}'", url)) - })?; - - let mut ollama_client = Ollama::new_with_client(url.clone(), port, http_client.clone()); - if !headers.is_empty() { - ollama_client.set_headers(Some(headers)); - } - - Ok((ollama_client, http_client)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::mcp::McpToolDescriptor; - use ollama_rs::generation::chat::ChatMessageFinalResponseData; - use serde_json::{Map as JsonMap, Value, json}; - use std::collections::HashMap; - - #[test] - fn resolve_api_key_prefers_literal_value() { - assert_eq!( - resolve_api_key(Some("direct-key".into())), - Some("direct-key".into()) - ); - } - - #[test] - fn resolve_api_key_expands_env_var() { - unsafe { - std::env::set_var("OLLAMA_TEST_KEY", "secret"); - } - assert_eq!( - resolve_api_key(Some("${OLLAMA_TEST_KEY}".into())), - Some("secret".into()) - ); - unsafe { - std::env::remove_var("OLLAMA_TEST_KEY"); - } - } - - #[test] - fn normalize_base_url_removes_api_path() { - let url = normalize_base_url(Some("https://ollama.com/api"), OllamaMode::Cloud).unwrap(); - assert_eq!(url, "https://ollama.com"); - } - - #[test] - fn normalize_base_url_accepts_v1_path_for_local() { - let url = normalize_base_url(Some("http://localhost:11434/v1"), OllamaMode::Local).unwrap(); - assert_eq!(url, "http://localhost:11434"); - } - - #[test] - fn normalize_base_url_accepts_v1_path_for_cloud() { - let url = normalize_base_url(Some("https://api.ollama.com/v1"), OllamaMode::Cloud).unwrap(); - assert_eq!(url, "https://ollama.com"); - } - - #[test] - fn normalize_base_url_canonicalises_api_hostname() { - let url = normalize_base_url(Some("https://api.ollama.com"), OllamaMode::Cloud).unwrap(); - assert_eq!(url, "https://ollama.com"); - } - - #[test] - fn normalize_base_url_rejects_cloud_without_https() { - let err = normalize_base_url(Some("http://ollama.com"), OllamaMode::Cloud).unwrap_err(); - assert!(err.contains("https")); - } - - #[test] - fn explicit_local_mode_overrides_api_key() { - let mut config = ProviderConfig { - enabled: true, - provider_type: "ollama".to_string(), - base_url: Some("http://localhost:11434".to_string()), - api_key: Some("secret-key".to_string()), - api_key_env: None, - extra: HashMap::new(), - }; - config.extra.insert( - OLLAMA_MODE_KEY.to_string(), - Value::String("local".to_string()), - ); - - let provider = OllamaProvider::from_config("ollama_local", &config, None) - .expect("provider constructed"); - - assert_eq!(provider.mode, OllamaMode::Local); - assert_eq!(provider.base_url, "http://localhost:11434"); - } - - #[test] - fn auto_mode_prefers_explicit_local_base() { - let config = ProviderConfig { - enabled: true, - provider_type: "ollama".to_string(), - base_url: Some("http://localhost:11434".to_string()), - api_key: Some("secret-key".to_string()), - api_key_env: None, - extra: HashMap::new(), - }; - // simulate missing explicit mode; defaults to auto - - let provider = OllamaProvider::from_config("ollama_local", &config, None) - .expect("provider constructed"); - - assert_eq!(provider.mode, OllamaMode::Local); - assert_eq!(provider.base_url, "http://localhost:11434"); - } - - #[test] - fn auto_mode_with_api_key_and_no_local_probe_switches_to_cloud() { - let mut config = ProviderConfig { - enabled: true, - provider_type: "ollama".to_string(), - base_url: None, - api_key: Some("secret-key".to_string()), - api_key_env: None, - extra: HashMap::new(), - }; - config.extra.insert( - OLLAMA_MODE_KEY.to_string(), - Value::String("auto".to_string()), - ); - - let provider = OllamaProvider::from_config("ollama_cloud", &config, None) - .expect("provider constructed"); - - assert_eq!(provider.mode, OllamaMode::Cloud); - assert_eq!(provider.base_url, CLOUD_BASE_URL); - } - - #[test] - fn cloud_provider_requires_api_key() { - let _primary = EnvVarGuard::clear(OLLAMA_API_KEY_ENV); - let _legacy_primary = EnvVarGuard::clear(LEGACY_OLLAMA_CLOUD_API_KEY_ENV); - let _legacy_secondary = EnvVarGuard::clear(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV); - - let config = ProviderConfig { - enabled: true, - provider_type: "ollama_cloud".to_string(), - base_url: None, - api_key: None, - api_key_env: None, - extra: HashMap::new(), - }; - - let err = OllamaProvider::from_config("ollama_cloud", &config, None) - .expect_err("expected config error"); - match err { - Error::Config(message) => { - assert!(message.contains("API key")); - } - other => panic!("unexpected error variant: {other:?}"), - } - } - - #[test] - fn cloud_provider_uses_explicit_api_key() { - let config = ProviderConfig { - enabled: true, - provider_type: "ollama_cloud".to_string(), - base_url: None, - api_key: Some("secret-cloud-key".to_string()), - api_key_env: None, - extra: HashMap::new(), - }; - - let provider = OllamaProvider::from_config("ollama_cloud", &config, None) - .expect("provider constructed"); - assert_eq!(provider.name(), "ollama_cloud"); - assert_eq!(provider.mode, OllamaMode::Cloud); - assert_eq!(provider.base_url, CLOUD_BASE_URL); - } - - #[test] - fn cloud_provider_reads_api_key_from_env_hint() { - let config = ProviderConfig { - enabled: true, - provider_type: "ollama_cloud".to_string(), - base_url: None, - api_key: None, - api_key_env: Some("OLLAMA_TEST_CLOUD_KEY".to_string()), - extra: HashMap::new(), - }; - - unsafe { - std::env::set_var("OLLAMA_TEST_CLOUD_KEY", "env-secret"); - } - - assert!(std::env::var("OLLAMA_TEST_CLOUD_KEY").is_ok()); - assert!(resolve_api_key_env_hint(config.api_key_env.as_deref()).is_some()); - assert_eq!(config.api_key_env.as_deref(), Some("OLLAMA_TEST_CLOUD_KEY")); - - let provider = OllamaProvider::from_config("ollama_cloud", &config, None) - .expect("provider constructed"); - assert_eq!(provider.name(), "ollama_cloud"); - assert_eq!(provider.mode, OllamaMode::Cloud); - - unsafe { - std::env::remove_var("OLLAMA_TEST_CLOUD_KEY"); - } - } - - #[test] - fn fetch_scope_tags_with_retry_success_uses_override() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - let handle = ScopeHandle::new( - provider.client.clone(), - provider.http_client.clone(), - provider.base_url.clone(), - ); - - let _guard = set_tags_override(vec![Ok(vec![LocalModel { - name: "llama3".into(), - modified_at: "2024-01-01T00:00:00Z".into(), - size: 42, - }])]); - - let models = block_on(provider.fetch_scope_tags_with_retry(OllamaMode::Local, &handle)) - .expect("models returned"); - assert_eq!(models.len(), 1); - assert_eq!(models[0].name, "llama3"); - } - - #[test] - fn convert_model_propagates_context_window_from_details() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - let local = LocalModel { - name: "gemma3n:e4b".into(), - modified_at: "2024-01-01T00:00:00Z".into(), - size: 0, - }; - - let mut meta = JsonMap::new(); - meta.insert( - "context_length".into(), - Value::Number(serde_json::Number::from(32_768)), - ); - - let detail = OllamaModelInfo { - license: String::new(), - modelfile: String::new(), - parameters: String::new(), - template: String::new(), - model_info: meta, - capabilities: vec![], - }; - - let info = provider.convert_model(OllamaMode::Local, local, Some(detail)); - assert_eq!(info.context_window, Some(32_768)); - } - - #[test] - fn fetch_scope_tags_with_retry_retries_on_timeout_then_succeeds() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - let handle = ScopeHandle::new( - provider.client.clone(), - provider.http_client.clone(), - provider.base_url.clone(), - ); - - let _guard = set_tags_override(vec![ - Err(Error::Timeout("first attempt".into())), - Ok(vec![LocalModel { - name: "llama3".into(), - modified_at: "2024-01-01T00:00:00Z".into(), - size: 42, - }]), - ]); - - let models = block_on(provider.fetch_scope_tags_with_retry(OllamaMode::Local, &handle)) - .expect("models returned after retry"); - assert_eq!(models.len(), 1); - assert_eq!(models[0].name, "llama3"); - } - - #[test] - fn decorate_scope_error_returns_friendly_message_for_connectivity() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - let (error, message) = provider.decorate_scope_error( - OllamaMode::Local, - "http://localhost:11434", - Error::Network("failed to connect to host".into()), - ); - - assert!(matches!( - error, - Error::Network(ref text) if text.contains("Ollama not reachable") - )); - assert!(message.contains("Ollama not reachable")); - assert!(message.contains("localhost:11434")); - } - - #[test] - fn decorate_scope_error_preserves_http_failure_message() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - let original = "Ollama list models failed (500): boom".to_string(); - let (error, message) = provider.decorate_scope_error( - OllamaMode::Local, - "http://localhost:11434", - Error::Network(original.clone()), - ); - - assert!(matches!(error, Error::Network(ref text) if text.contains("500"))); - assert_eq!(message, original); - } - - #[test] - fn decorate_scope_error_translates_timeout() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - let (error, message) = provider.decorate_scope_error( - OllamaMode::Local, - "http://localhost:11434", - Error::Timeout("deadline exceeded".into()), - ); - - assert!(matches!( - error, - Error::Timeout(ref text) if text.contains("Ollama not reachable") - )); - assert!(message.contains("Ollama not reachable")); - } - - #[test] - fn map_http_failure_model_not_found_suggests_pull_hint() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - let err = provider.map_http_failure( - "chat", - StatusCode::NOT_FOUND, - "missing model".to_string(), - Some("llama3"), - ); - - let message = match err { - Error::InvalidInput(message) => message, - other => panic!("unexpected error variant: {other:?}"), - }; - - assert!(message.contains("ollama pull llama3")); - } - - #[test] - fn build_model_options_merges_parameters() { - let mut parameters = ChatParameters::default(); - parameters.temperature = Some(0.3); - parameters.max_tokens = Some(128); - parameters - .extra - .insert("num_ctx".into(), Value::from(4096_u64)); - - let options = build_model_options(¶meters) - .expect("options built") - .expect("options present"); - let serialized = serde_json::to_value(&options).expect("serialize options"); - let temperature = serialized["temperature"] - .as_f64() - .expect("temperature present"); - assert!((temperature - 0.3).abs() < 1e-6); - assert_eq!(serialized["num_predict"], json!(128)); - assert_eq!(serialized["num_ctx"], json!(4096)); - } - - #[test] - fn prepare_chat_request_serializes_tool_descriptors() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - - let descriptor = McpToolDescriptor { - name: crate::tools::WEB_SEARCH_TOOL_NAME.to_string(), - description: "Perform a web search".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "query": {"type": "string"} - }, - "required": ["query"] - }), - requires_network: true, - requires_filesystem: Vec::new(), - }; - - let (_model_id, request) = provider - .prepare_chat_request( - "llama3".to_string(), - vec![Message::user("Hello".to_string())], - ChatParameters::default(), - Some(vec![descriptor.clone()]), - ) - .expect("request built"); - - assert_eq!(request.tools.len(), 1); - let tool = &request.tools[0]; - assert_eq!(tool.function.name, descriptor.name); - assert_eq!(tool.function.description, descriptor.description); - - let serialized = serde_json::to_value(&tool.function.parameters).expect("serialize schema"); - assert_eq!(serialized, descriptor.input_schema); - } - - #[test] - fn convert_model_marks_tool_capability() { - let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); - - let local = LocalModel { - name: "llama3-tool".to_string(), - modified_at: "2025-10-23T00:00:00Z".to_string(), - size: 0, - }; - - let detail = OllamaModelInfo { - license: String::new(), - modelfile: String::new(), - parameters: String::new(), - template: String::new(), - model_info: JsonMap::new(), - capabilities: vec!["function_call".to_string()], - }; - - let info = provider.convert_model(OllamaMode::Local, local, Some(detail)); - assert!(info.supports_tools); - } - - #[test] - fn convert_response_attaches_provider_metadata() { - let final_data = ChatMessageFinalResponseData { - total_duration: 10, - load_duration: 2, - prompt_eval_count: 42, - prompt_eval_duration: 4, - eval_count: 21, - eval_duration: 6, - }; - - let response = OllamaChatResponse { - model: "llama3".to_string(), - created_at: "2025-10-23T18:00:00Z".to_string(), - message: OllamaMessage { - role: OllamaRole::Assistant, - content: "Tool output incoming".to_string(), - tool_calls: Vec::new(), - images: None, - thinking: None, - }, - done: true, - final_data: Some(final_data), - }; - - let chunk = OllamaProvider::convert_ollama_response(response, false); - - let metadata = chunk - .message - .metadata - .get("ollama") - .and_then(Value::as_object) - .expect("ollama metadata present"); - assert_eq!( - metadata.get("model").and_then(Value::as_str), - Some("llama3") - ); - assert!(metadata.contains_key("final_data")); - assert_eq!( - metadata.get("created_at").and_then(Value::as_str).unwrap(), - "2025-10-23T18:00:00Z" - ); - - let usage = chunk.usage.expect("usage populated"); - assert_eq!(usage.prompt_tokens, 42); - assert_eq!(usage.completion_tokens, 21); - } - - #[test] - fn heuristic_capabilities_detects_thinking_models() { - let caps = heuristic_capabilities("deepseek-r1"); - assert!(caps.iter().any(|cap| cap == "thinking")); - } - - #[test] - fn push_capability_avoids_duplicates() { - let mut caps = vec!["chat".to_string()]; - push_capability(&mut caps, "Chat"); - push_capability(&mut caps, "Vision"); - push_capability(&mut caps, "vision"); - - assert_eq!(caps.len(), 2); - assert!(caps.iter().any(|cap| cap == "vision")); - } -} - -#[cfg(test)] -struct ProbeOverrideGuard { - gate: Option>, -} - -#[cfg(test)] -impl ProbeOverrideGuard { - fn set(value: Option) -> Self { - let gate = PROBE_OVERRIDE_GATE - .get_or_init(|| Mutex::new(())) - .lock() - .expect("probe override gate mutex poisoned"); - set_probe_override(value); - ProbeOverrideGuard { gate: Some(gate) } - } -} - -#[cfg(test)] -impl Drop for ProbeOverrideGuard { - fn drop(&mut self) { - set_probe_override(None); - self.gate.take(); - } -} - -#[cfg(test)] -struct EnvVarGuard { - key: &'static str, - original: Option, -} - -#[cfg(test)] -impl EnvVarGuard { - fn clear(key: &'static str) -> Self { - let original = std::env::var(key).ok(); - unsafe { - std::env::remove_var(key); - } - Self { key, original } - } -} - -#[cfg(test)] -impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.original { - Some(value) => unsafe { - std::env::set_var(self.key, value); - }, - None => unsafe { - std::env::remove_var(self.key); - }, - } - } -} - -#[test] -fn auto_mode_with_api_key_and_successful_probe_prefers_local() { - let _guard = ProbeOverrideGuard::set(Some(true)); - - let mut config = ProviderConfig { - enabled: true, - provider_type: "ollama".to_string(), - base_url: None, - api_key: Some("secret-key".to_string()), - api_key_env: None, - extra: HashMap::new(), - }; - config.extra.insert( - OLLAMA_MODE_KEY.to_string(), - Value::String("auto".to_string()), - ); - - assert!(probe_default_local_daemon(Duration::from_millis(1))); - - let provider = - OllamaProvider::from_config("ollama_local", &config, None).expect("provider constructed"); - - assert_eq!(provider.mode, OllamaMode::Local); - assert_eq!(provider.base_url, "http://localhost:11434"); -} - -#[test] -fn auto_mode_with_api_key_and_failed_probe_prefers_cloud() { - let _guard = ProbeOverrideGuard::set(Some(false)); - - let mut config = ProviderConfig { - enabled: true, - provider_type: "ollama".to_string(), - base_url: None, - api_key: Some("secret-key".to_string()), - api_key_env: None, - extra: HashMap::new(), - }; - config.extra.insert( - OLLAMA_MODE_KEY.to_string(), - Value::String("auto".to_string()), - ); - - let provider = - OllamaProvider::from_config("ollama_cloud", &config, None).expect("provider constructed"); - - assert_eq!(provider.mode, OllamaMode::Cloud); - assert_eq!(provider.base_url, CLOUD_BASE_URL); -} - -#[test] -fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() { - let config = ProviderConfig { - enabled: true, - provider_type: "ollama".to_string(), - base_url: Some("http://localhost:11434".to_string()), - api_key: None, - api_key_env: None, - extra: HashMap::new(), - }; - - let provider = - OllamaProvider::from_config("ollama_local", &config, None).expect("provider constructed"); - - let mut models = vec![ModelInfo { - id: "llama3".to_string(), - name: "Llama 3".to_string(), - description: None, - provider: "ollama".to_string(), - context_window: None, - capabilities: vec!["scope:local".to_string()], - supports_tools: false, - }]; - - block_on(async { - { - let mut cache = provider.scope_cache.write().await; - let entry = cache.entry(OllamaMode::Cloud).or_default(); - entry.availability = ScopeAvailability::Unavailable; - entry.last_error = Some("Cloud endpoint unreachable".to_string()); - entry.last_checked = Some(Instant::now()); - } - - provider.annotate_scope_status(&mut models).await; - }); - - let capabilities = &models[0].capabilities; - assert!( - capabilities - .iter() - .any(|cap| cap == "scope-status:cloud:unavailable") - ); - assert!( - capabilities - .iter() - .any(|cap| cap.starts_with("scope-status-message:cloud:")) - ); - assert!( - capabilities - .iter() - .any(|cap| cap.starts_with("scope-status-age:cloud:")) - ); - assert!( - capabilities - .iter() - .any(|cap| cap == "scope-status-stale:cloud:0") - ); -} diff --git a/crates/owlen-core/src/router.rs b/crates/owlen-core/src/router.rs deleted file mode 100644 index cea9206..0000000 --- a/crates/owlen-core/src/router.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Router for managing multiple providers and routing requests - -use crate::{Result, llm::*, types::*}; -use anyhow::anyhow; -use std::sync::Arc; - -/// A router that can distribute requests across multiple providers -pub struct Router { - registry: ProviderRegistry, - routing_rules: Vec, - default_provider: Option, -} - -/// A rule for routing requests to specific providers -#[derive(Debug, Clone)] -pub struct RoutingRule { - /// Pattern to match against model names - pub model_pattern: String, - /// Provider to route to - pub provider: String, - /// Priority (higher numbers are checked first) - pub priority: u32, -} - -impl Router { - /// Create a new router - pub fn new() -> Self { - Self { - registry: ProviderRegistry::new(), - routing_rules: Vec::new(), - default_provider: None, - } - } - - /// Register a provider with the router - pub fn register_provider(&mut self, provider: P) { - self.registry.register(provider); - } - - /// Set the default provider - pub fn set_default_provider(&mut self, provider_name: String) { - self.default_provider = Some(provider_name); - } - - /// Add a routing rule - pub fn add_routing_rule(&mut self, rule: RoutingRule) { - self.routing_rules.push(rule); - // Sort by priority (descending) - self.routing_rules - .sort_by(|a, b| b.priority.cmp(&a.priority)); - } - - /// Route a request to the appropriate provider - pub async fn chat(&self, request: ChatRequest) -> Result { - let provider = self.find_provider_for_model(&request.model)?; - provider.send_prompt(request).await - } - - /// Route a streaming request to the appropriate provider - pub async fn chat_stream(&self, request: ChatRequest) -> Result { - let provider = self.find_provider_for_model(&request.model)?; - provider.stream_prompt(request).await - } - - /// List all available models from all providers - pub async fn list_models(&self) -> Result> { - self.registry.list_all_models().await - } - - /// Find the appropriate provider for a given model - fn find_provider_for_model(&self, model: &str) -> Result> { - // Check routing rules first - for rule in &self.routing_rules { - if !self.matches_pattern(&rule.model_pattern, model) { - continue; - } - if let Some(provider) = self.registry.get(&rule.provider) { - return Ok(provider); - } - } - - // Fall back to default provider - if let Some(provider) = self - .default_provider - .as_ref() - .and_then(|default| self.registry.get(default)) - { - return Ok(provider); - } - - // If no default, try to find any provider that has this model - // This is a fallback for cases where routing isn't configured - for provider_name in self.registry.list_providers() { - if let Some(provider) = self.registry.get(&provider_name) { - return Ok(provider); - } - } - - Err(crate::Error::Provider(anyhow!( - "No provider found for model: {}", - model - ))) - } - - /// Check if a model name matches a pattern - fn matches_pattern(&self, pattern: &str, model: &str) -> bool { - // Simple pattern matching for now - // Could be extended to support more complex patterns - if pattern == "*" { - return true; - } - - if let Some(prefix) = pattern.strip_suffix('*') { - return model.starts_with(prefix); - } - - if let Some(suffix) = pattern.strip_prefix('*') { - return model.ends_with(suffix); - } - - pattern == model - } - - /// Get routing configuration - pub fn get_routing_rules(&self) -> &[RoutingRule] { - &self.routing_rules - } - - /// Get the default provider name - pub fn get_default_provider(&self) -> Option<&str> { - self.default_provider.as_deref() - } -} - -impl Default for Router { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pattern_matching() { - let router = Router::new(); - - assert!(router.matches_pattern("*", "any-model")); - assert!(router.matches_pattern("gpt*", "gpt-4")); - assert!(router.matches_pattern("gpt*", "gpt-3.5-turbo")); - assert!(!router.matches_pattern("gpt*", "claude-3")); - assert!(router.matches_pattern("*:latest", "llama2:latest")); - assert!(router.matches_pattern("exact-match", "exact-match")); - assert!(!router.matches_pattern("exact-match", "different-model")); - } -} diff --git a/crates/owlen-core/src/sandbox.rs b/crates/owlen-core/src/sandbox.rs deleted file mode 100644 index 94f00f4..0000000 --- a/crates/owlen-core/src/sandbox.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result, bail}; -use tempfile::TempDir; - -/// Configuration options for sandboxed process execution. -#[derive(Clone, Debug)] -pub struct SandboxConfig { - pub allow_network: bool, - pub allow_paths: Vec, - pub readonly_paths: Vec, - pub timeout_seconds: u64, - pub max_memory_mb: u64, -} - -impl Default for SandboxConfig { - fn default() -> Self { - Self { - allow_network: false, - allow_paths: Vec::new(), - readonly_paths: Vec::new(), - timeout_seconds: 30, - max_memory_mb: 512, - } - } -} - -/// Wrapper around a bubblewrap sandbox instance. -/// -/// Memory limits are enforced via: -/// - bwrap's --rlimit-as (version >= 0.12.0) -/// - prlimit wrapper (fallback for older bwrap versions) -/// - timeout mechanism (always enforced as last resort) -pub struct SandboxedProcess { - temp_dir: TempDir, - config: SandboxConfig, -} - -impl SandboxedProcess { - pub fn new(config: SandboxConfig) -> Result { - let temp_dir = TempDir::new().context("Failed to create temp directory")?; - - which::which("bwrap") - .context("bubblewrap not found. Install with: sudo apt install bubblewrap")?; - - Ok(Self { temp_dir, config }) - } - - pub fn execute(&self, command: &str, args: &[&str]) -> Result { - let supports_rlimit = self.supports_rlimit_as(); - let use_prlimit = !supports_rlimit && which::which("prlimit").is_ok(); - - let mut cmd = if use_prlimit { - // Use prlimit wrapper for older bwrap versions - let mut prlimit_cmd = Command::new("prlimit"); - let memory_limit_bytes = self - .config - .max_memory_mb - .saturating_mul(1024) - .saturating_mul(1024); - prlimit_cmd.arg(format!("--as={}", memory_limit_bytes)); - prlimit_cmd.arg("bwrap"); - prlimit_cmd - } else { - Command::new("bwrap") - }; - - cmd.args(["--unshare-all", "--die-with-parent", "--new-session"]); - - if self.config.allow_network { - cmd.arg("--share-net"); - } else { - cmd.arg("--unshare-net"); - } - - cmd.args(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]); - - // Bind essential system paths readonly for executables and libraries - let system_paths = ["/usr", "/bin", "/lib", "/lib64", "/etc"]; - for sys_path in &system_paths { - let path = std::path::Path::new(sys_path); - if path.exists() { - cmd.arg("--ro-bind").arg(sys_path).arg(sys_path); - } - } - - // Bind /run for DNS resolution (resolv.conf may be a symlink to /run/systemd/resolve/*) - if std::path::Path::new("/run").exists() { - cmd.arg("--ro-bind").arg("/run").arg("/run"); - } - - for path in &self.config.allow_paths { - let path_host = path.to_string_lossy().into_owned(); - let path_guest = path_host.clone(); - cmd.arg("--bind").arg(&path_host).arg(&path_guest); - } - - for path in &self.config.readonly_paths { - let path_host = path.to_string_lossy().into_owned(); - let path_guest = path_host.clone(); - cmd.arg("--ro-bind").arg(&path_host).arg(&path_guest); - } - - let work_dir = self.temp_dir.path().to_string_lossy().into_owned(); - cmd.arg("--bind").arg(&work_dir).arg("/work"); - cmd.arg("--chdir").arg("/work"); - - // Add memory limits via bwrap's --rlimit-as if supported (version >= 0.12.0) - // If not supported, we use prlimit wrapper (set earlier) - if supports_rlimit && !use_prlimit { - let memory_limit_bytes = self - .config - .max_memory_mb - .saturating_mul(1024) - .saturating_mul(1024); - let memory_soft = memory_limit_bytes.to_string(); - let memory_hard = memory_limit_bytes.to_string(); - cmd.arg("--rlimit-as").arg(&memory_soft).arg(&memory_hard); - } - - cmd.arg(command); - cmd.args(args); - - let start = Instant::now(); - let timeout = Duration::from_secs(self.config.timeout_seconds); - - // Spawn the process instead of waiting immediately - let mut child = cmd - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("Failed to spawn sandboxed command")?; - - let mut was_timeout = false; - - // Wait for the child with timeout - let output = loop { - match child.try_wait() { - Ok(Some(_status)) => { - // Process exited - let output = child - .wait_with_output() - .context("Failed to collect process output")?; - break output; - } - Ok(None) => { - // Process still running, check timeout - if start.elapsed() >= timeout { - // Timeout exceeded, kill the process - was_timeout = true; - child.kill().context("Failed to kill timed-out process")?; - // Wait for the killed process to exit - let output = child - .wait_with_output() - .context("Failed to collect output from killed process")?; - break output; - } - // Sleep briefly before checking again - std::thread::sleep(Duration::from_millis(50)); - } - Err(e) => { - bail!("Failed to check process status: {}", e); - } - } - }; - - let duration = start.elapsed(); - - Ok(SandboxResult { - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - exit_code: output.status.code().unwrap_or(-1), - duration, - was_timeout, - }) - } - - /// Check if bubblewrap supports --rlimit-as option (version >= 0.12.0) - fn supports_rlimit_as(&self) -> bool { - // Try to get bwrap version - let output = Command::new("bwrap").arg("--version").output(); - - if let Ok(output) = output { - let version_str = String::from_utf8_lossy(&output.stdout); - // Parse version like "bubblewrap 0.11.0" or "0.11.0" - return version_str - .split_whitespace() - .last() - .and_then(|part| { - part.split_once('.').and_then(|(major, rest)| { - rest.split_once('.').and_then(|(minor, _)| { - let maj = major.parse::().ok()?; - let min = minor.parse::().ok()?; - Some((maj, min)) - }) - }) - }) - .map(|(maj, min)| maj > 0 || (maj == 0 && min >= 12)) - .unwrap_or(false); - } - - // If we can't determine the version, assume it doesn't support it (safer default) - false - } -} - -#[derive(Debug, Clone)] -pub struct SandboxResult { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, - pub duration: Duration, - pub was_timeout: bool, -} diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs deleted file mode 100644 index 26a7102..0000000 --- a/crates/owlen-core/src/session.rs +++ /dev/null @@ -1,2641 +0,0 @@ -use crate::config::{ - ApprovalMode, ChatSettings, CompressionStrategy, Config, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, - LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, McpResourceConfig, McpServerConfig, OLLAMA_API_KEY_ENV, - OLLAMA_CLOUD_BASE_URL, -}; -use crate::consent::{ConsentManager, ConsentScope}; -use crate::conversation::ConversationManager; -use crate::credentials::CredentialManager; -use crate::encryption::{self, VaultHandle}; -use crate::formatting::MessageFormatter; -use crate::input::InputBuffer; -use crate::llm::ProviderConfig; -use crate::mcp::McpToolCall; -use crate::mcp::client::McpClient; -use crate::mcp::factory::McpClientFactory; -use crate::mcp::permission::PermissionLayer; -use crate::mcp::remote_client::{McpRuntimeSecrets, RemoteMcpClient}; -use crate::mode::Mode; -use crate::model::{DetailedModelInfo, ModelManager}; -use crate::oauth::{DeviceAuthorization, DevicePollState, OAuthClient}; -use crate::providers::OllamaProvider; -use crate::providers::ollama::normalize_cloud_base_url; -use crate::storage::{SessionMeta, StorageManager}; -use crate::tools::{WEB_SEARCH_TOOL_NAME, canonical_tool_name, tool_name_matches}; -use crate::types::{ - ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, Role, ToolCall, -}; -use crate::ui::{RoleLabelDisplay, UiController}; -use crate::usage::{UsageLedger, UsageQuota, UsageSnapshot}; -use crate::validation::{SchemaValidator, get_builtin_schemas}; -use crate::{ChatStream, Provider}; -use crate::{ - CodeExecTool, ResourcesDeleteTool, ResourcesGetTool, ResourcesListTool, ResourcesWriteTool, - ToolRegistry, WebScrapeTool, WebSearchSettings, WebSearchTool, -}; -use crate::{Error, Result}; -use chrono::{DateTime, Utc}; -use log::{debug, info, warn}; -use reqwest::Url; -use serde_json::{Value, json}; -use std::cmp::{max, min}; -use std::collections::{HashMap, HashSet}; -use std::env; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime}; -use tokio::fs; -use tokio::sync::Mutex as TokioMutex; -use tokio::sync::mpsc::UnboundedSender; -use uuid::Uuid; - -fn env_var_non_empty(name: &str) -> Option { - env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn estimate_tokens(messages: &[Message]) -> u32 { - messages - .iter() - .map(estimate_message_tokens) - .fold(0u32, |acc, value| acc.saturating_add(value)) -} - -fn estimate_message_tokens(message: &Message) -> u32 { - let content = message.content.trim(); - let base = if content.is_empty() { - 4 - } else { - let approx = max(4, content.chars().count() / 4 + 1); - approx + 4 - } as u32; - - message.attachments.iter().fold(base, |acc, attachment| { - let mut bonus: u32 = if attachment.is_image() { 256 } else { 64 }; - - if let Some(text) = attachment.text_data() { - let text_tokens = max(4, text.chars().count() / 4 + 1) as u32; - bonus = bonus.max(text_tokens); - } - - if let Some(size) = attachment.size_bytes { - let kilobytes = ((size as f64) / 1024.0).ceil() as u32; - let size_tokens = kilobytes.saturating_mul(8).max(32); - bonus = bonus.max(size_tokens); - } - - acc.saturating_add(bonus) - }) -} - -fn build_transcript(messages: &[Message]) -> String { - let mut transcript = String::new(); - let take = min(messages.len(), MAX_TRANSCRIPT_MESSAGES); - for message in messages.iter().take(take) { - let role = match message.role { - Role::User => "User", - Role::Assistant => "Assistant", - Role::System => "System", - Role::Tool => "Tool", - }; - let snippet = sanitize_snippet(&message.content); - if snippet.is_empty() && message.attachments.is_empty() { - continue; - } - transcript.push_str(&format!("{role}: {snippet}\n")); - if !message.attachments.is_empty() { - for attachment in &message.attachments { - let name = attachment - .name - .as_deref() - .unwrap_or(attachment.mime_type.as_str()); - let mut summary = format!(" • Attachment {name} ({})", attachment.mime_type); - if let Some(size) = attachment.size_bytes { - let kb = (size as f64) / 1024.0; - summary.push_str(&format!(" · {:.1} KB", kb)); - } - if let Some(text) = attachment.text_data() { - let trimmed = text.trim(); - if !trimmed.is_empty() { - let mut preview = trimmed.chars().take(60).collect::(); - if trimmed.chars().count() > 60 { - preview.push('…'); - } - summary.push_str(&format!(" · {}", preview)); - } - } - transcript.push_str(&summary); - transcript.push('\n'); - } - } - transcript.push('\n'); - } - if messages.len() > take { - transcript.push_str(&format!( - "... ({} additional messages omitted for brevity)\n", - messages.len() - take - )); - } - transcript -} - -fn local_summary(messages: &[Message]) -> String { - if messages.is_empty() { - return "(no content to summarize)".to_string(); - } - let total = messages.len(); - let mut summary = String::from("Summary (local heuristic)\n\n"); - summary.push_str(&format!("- Compressed {total} prior messages.\n")); - - let recent_users = collect_recent_by_role(messages, Role::User, 3); - if !recent_users.is_empty() { - summary.push_str("- Recent user intents:\n"); - for intent in recent_users { - summary.push_str(&format!(" - {intent}\n")); - } - } - - let recent_assistant = collect_recent_by_role(messages, Role::Assistant, 3); - if !recent_assistant.is_empty() { - summary.push_str("- Recent assistant responses:\n"); - for reply in recent_assistant { - summary.push_str(&format!(" - {reply}\n")); - } - } - - summary.trim_end().to_string() -} - -fn collect_recent_by_role(messages: &[Message], role: Role, limit: usize) -> Vec { - if limit == 0 { - return Vec::new(); - } - let mut results = Vec::new(); - for message in messages.iter().rev() { - if message.role == role { - let snippet = sanitize_snippet(&message.content); - if !snippet.is_empty() { - results.push(snippet); - if results.len() == limit { - break; - } - continue; - } - - if !message.attachments.is_empty() { - let names = message - .attachments - .iter() - .map(|attachment| { - attachment - .name - .as_deref() - .unwrap_or(attachment.mime_type.as_str()) - }) - .collect::>() - .join(", "); - if !names.is_empty() { - results.push(format!("Attachments: {}", names)); - } else { - results.push("Attachments received".to_string()); - } - if results.len() == limit { - break; - } - } - } - } - results.reverse(); - results -} - -fn sanitize_snippet(content: &str) -> String { - let trimmed = content.trim(); - if trimmed.is_empty() { - return String::new(); - } - let mut snippet = trimmed.replace('\r', ""); - if snippet.len() > MAX_TRANSCRIPT_MESSAGE_CHARS { - snippet.truncate(MAX_TRANSCRIPT_MESSAGE_CHARS); - snippet.push_str("..."); - } - snippet -} - -fn compute_web_search_settings( - config: &Config, - provider_id: &str, -) -> Result> { - let provider_id = provider_id.trim(); - let provider_config = match config.providers.get(provider_id) { - Some(cfg) => cfg, - None => return Ok(None), - }; - - if !provider_config.enabled { - return Ok(None); - } - - if provider_config - .provider_type - .trim() - .eq_ignore_ascii_case("ollama") - { - // Local Ollama does not expose web search. - return Ok(None); - } - - if !provider_config - .provider_type - .trim() - .eq_ignore_ascii_case("ollama_cloud") - { - return Ok(None); - } - - let raw_base_url = provider_config - .base_url - .as_deref() - .filter(|value| !value.trim().is_empty()); - let normalized_base_url = normalize_cloud_base_url(raw_base_url).map_err(|err| { - let display_base = raw_base_url.unwrap_or(OLLAMA_CLOUD_BASE_URL); - Error::Config(format!( - "Invalid Ollama Cloud base_url '{}': {err}", - display_base - )) - })?; - - let endpoint = provider_config - .extra - .get("web_search_endpoint") - .and_then(|value| value.as_str()) - .unwrap_or("/api/web_search"); - - let endpoint_url = build_search_url(&normalized_base_url, endpoint)?; - - let api_key = resolve_web_search_api_key(provider_config) - .or_else(|| env_var_non_empty(OLLAMA_API_KEY_ENV)) - .or_else(|| env_var_non_empty(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)) - .or_else(|| env_var_non_empty(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)); - - let api_key = match api_key { - Some(key) if !key.is_empty() => key, - _ => return Ok(None), - }; - - let settings = WebSearchSettings { - endpoint: endpoint_url, - api_key, - provider_label: provider_id.to_string(), - timeout: Duration::from_secs(20), - }; - - Ok(Some(settings)) -} - -fn resolve_web_search_api_key(provider_config: &ProviderConfig) -> Option { - resolve_inline_api_key(provider_config.api_key.as_deref()).or_else(|| { - provider_config - .api_key_env - .as_deref() - .and_then(|var| env_var_non_empty(var.trim())) - }) -} - -fn resolve_inline_api_key(value: Option<&str>) -> Option { - let raw = value?.trim(); - if raw.is_empty() { - return None; - } - - if let Some(inner) = raw - .strip_prefix("${") - .and_then(|value| value.strip_suffix('}')) - .map(str::trim) - { - return env_var_non_empty(inner); - } - - if let Some(inner) = raw.strip_prefix('$').map(str::trim) { - return env_var_non_empty(inner); - } - - Some(raw.to_string()) -} - -fn build_search_url(base_url: &str, endpoint: &str) -> Result { - let endpoint = endpoint.trim(); - if let Ok(url) = Url::parse(endpoint) { - return Ok(url); - } - - let trimmed_base = base_url.trim(); - let normalized_base = if trimmed_base.ends_with('/') { - trimmed_base.to_string() - } else { - format!("{}/", trimmed_base) - }; - - let base = Url::parse(&normalized_base).map_err(|err| { - Error::Config(format!("Invalid provider base_url '{}': {}", base_url, err)) - })?; - - if endpoint.is_empty() { - return Ok(base); - } - - base.join(endpoint.trim_start_matches('/')).map_err(|err| { - Error::Config(format!( - "Invalid web_search_endpoint '{}': {}", - endpoint, err - )) - }) -} - -pub enum SessionOutcome { - Complete(ChatResponse), - Streaming { - response_id: Uuid, - stream: ChatStream, - }, -} - -#[derive(Debug, Clone)] -pub enum ControllerEvent { - ToolRequested { - request_id: Uuid, - message_id: Uuid, - tool_name: String, - data_types: Vec, - endpoints: Vec, - tool_calls: Vec, - }, - CompressionCompleted { - report: CompressionReport, - }, -} - -#[derive(Clone, Debug)] -struct PendingToolRequest { - message_id: Uuid, - tool_name: String, - data_types: Vec, - endpoints: Vec, - tool_calls: Vec, -} - -#[derive(Debug, Clone)] -pub struct CompressionReport { - pub summary_message_id: Uuid, - pub compressed_messages: usize, - pub estimated_tokens_before: u32, - pub estimated_tokens_after: u32, - pub strategy: CompressionStrategy, - pub model_used: String, - pub retained_recent: usize, - pub automated: bool, - pub timestamp: DateTime, -} - -#[derive(Debug, Clone)] -struct CompressionOptions { - trigger_tokens: u32, - retain_recent: usize, - strategy: CompressionStrategy, - model_override: Option, -} - -impl CompressionOptions { - fn from_settings(settings: &ChatSettings) -> Self { - Self { - trigger_tokens: settings.trigger_tokens.max(64), - retain_recent: settings.retain_recent_messages.max(2), - strategy: settings.strategy, - model_override: settings.model_override.clone(), - } - } - - fn min_chunk_messages(&self) -> usize { - self.retain_recent.saturating_add(2).max(4) - } - - fn resolve_model<'a>(&'a self, active_model: &'a str) -> String { - self.model_override - .clone() - .filter(|model| !model.trim().is_empty()) - .unwrap_or_else(|| active_model.to_string()) - } -} - -const MAX_TRANSCRIPT_MESSAGE_CHARS: usize = 1024; -const MAX_TRANSCRIPT_MESSAGES: usize = 32; -const COMPRESSION_METADATA_KEY: &str = "compression"; - -#[derive(Debug, Default)] -struct StreamingMessageState { - full_text: String, - last_tool_calls: Option>, - finished: bool, -} - -#[derive(Debug)] -struct StreamDiff { - text: Option, - tool_calls: Option>, -} - -#[derive(Debug)] -struct TextDelta { - content: String, - mode: TextDeltaKind, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TextDeltaKind { - Append, - Replace, -} - -impl StreamingMessageState { - fn new() -> Self { - Self::default() - } - - fn ingest(&mut self, chunk: &ChatResponse) -> StreamDiff { - if self.finished { - return StreamDiff { - text: None, - tool_calls: None, - }; - } - - let mut text_delta = None; - let incoming = chunk.message.content.clone(); - - if incoming != self.full_text { - if incoming.starts_with(&self.full_text) { - let delta = incoming[self.full_text.len()..].to_string(); - if !delta.is_empty() { - text_delta = Some(TextDelta { - content: delta, - mode: TextDeltaKind::Append, - }); - } - } else { - text_delta = Some(TextDelta { - content: incoming.clone(), - mode: TextDeltaKind::Replace, - }); - } - self.full_text = incoming; - } - - let mut tool_delta = None; - if let Some(tool_calls) = chunk.message.tool_calls.clone() { - if tool_calls.is_empty() { - let previously_had_calls = self - .last_tool_calls - .as_ref() - .map(|prev| !prev.is_empty()) - .unwrap_or(false); - if previously_had_calls { - tool_delta = Some(Vec::new()); - } - self.last_tool_calls = None; - } else { - let is_new = self - .last_tool_calls - .as_ref() - .map(|prev| prev != &tool_calls) - .unwrap_or(true); - if is_new { - tool_delta = Some(tool_calls.clone()); - } - self.last_tool_calls = Some(tool_calls); - } - } - - StreamDiff { - text: text_delta, - tool_calls: tool_delta, - } - } - - fn mark_finished(&mut self) { - self.finished = true; - } -} - -#[derive(Debug, Clone)] -pub struct ToolConsentResolution { - pub request_id: Uuid, - pub message_id: Uuid, - pub tool_name: String, - pub scope: ConsentScope, - pub tool_calls: Vec, -} - -fn extract_resource_content(value: &Value) -> Option { - match value { - Value::Null => Some(String::new()), - Value::Bool(flag) => Some(flag.to_string()), - Value::Number(num) => Some(num.to_string()), - Value::String(text) => Some(text.clone()), - Value::Array(items) => { - let mut segments = Vec::new(); - for item in items { - if let Some(segment) = - extract_resource_content(item).filter(|segment| !segment.is_empty()) - { - segments.push(segment); - } - } - if segments.is_empty() { - None - } else { - Some(segments.join("\n")) - } - } - Value::Object(map) => { - const PREFERRED_FIELDS: [&str; 6] = - ["content", "contents", "text", "value", "body", "data"]; - for key in PREFERRED_FIELDS.iter() { - if let Some(text) = map - .get(*key) - .and_then(extract_resource_content) - .filter(|text| !text.is_empty()) - { - return Some(text); - } - } - - if let Some(text) = map - .get("chunks") - .and_then(extract_resource_content) - .filter(|text| !text.is_empty()) - { - return Some(text); - } - - None - } - } -} - -pub struct SessionController { - provider: Arc, - conversation: ConversationManager, - model_manager: ModelManager, - input_buffer: InputBuffer, - formatter: MessageFormatter, - config: Arc>, - consent_manager: Arc>, - tool_registry: Arc, - schema_validator: Arc, - mcp_client: Arc, - named_mcp_clients: HashMap>, - storage: Arc, - vault: Option>>, - master_key: Option>>, - credential_manager: Option>, - ui: Arc, - enable_code_tools: bool, - approval_mode: ApprovalMode, - plan_confirmed: bool, - plan_instruction_inserted: bool, - current_mode: Mode, - missing_oauth_servers: Vec, - event_tx: Option>, - pending_tool_requests: HashMap, - stream_states: HashMap, - usage_ledger: Arc>, - last_compression: Option, -} - -async fn build_tools( - config: Arc>, - ui: Arc, - enable_code_tools: bool, - consent_manager: Arc>, - _credential_manager: Option>, - _vault: Option>>, -) -> Result<(Arc, Arc)> { - let mut registry = ToolRegistry::new(config.clone(), ui); - let mut validator = SchemaValidator::new(); - // Acquire config asynchronously to avoid blocking the async runtime. - let config_guard = config.lock().await; - - for (name, schema) in get_builtin_schemas() { - if let Err(err) = validator.register_schema(&name, schema) { - warn!("Failed to register built-in schema {name}: {err}"); - } - } - - let active_provider_id = config_guard.general.default_provider.clone(); - - let approval_mode = config_guard.security.approval_mode; - let read_only = matches!(approval_mode, ApprovalMode::ReadOnly); - if read_only { - debug!("Approval mode 'read-only' active: suppressing write/delete/code-exec tools."); - } - - let web_search_settings = if config_guard - .security - .allowed_tools - .iter() - .any(|tool| tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)) - && config_guard.tools.web_search.enabled - && config_guard.privacy.enable_remote_search - { - match compute_web_search_settings(&config_guard, &active_provider_id) { - Ok(settings) => settings, - Err(err) => { - warn!("Skipping web_search tool: {}", err); - None - } - } - } else { - None - }; - - if let Some(settings) = web_search_settings { - let tool = WebSearchTool::new(consent_manager.clone(), settings); - registry.register(tool)?; - } - - // Register web_scrape tool if allowed. - if config_guard - .security - .allowed_tools - .iter() - .any(|tool| tool_name_matches(tool, "web_scrape")) - && config_guard.tools.web_search.enabled // reuse web_search toggle for simplicity - && config_guard.privacy.enable_remote_search - { - let tool = WebScrapeTool::new(); - registry.register(tool)?; - } - - if enable_code_tools - && !read_only - && config_guard - .security - .allowed_tools - .iter() - .any(|tool| tool == "code_exec") - && config_guard.tools.code_exec.enabled - { - let tool = CodeExecTool::new(config_guard.tools.code_exec.allowed_languages.clone()); - registry.register(tool)?; - } - - registry.register(ResourcesListTool)?; - registry.register(ResourcesGetTool)?; - - if config_guard - .security - .allowed_tools - .iter() - .any(|t| t == "file_write") - && !read_only - { - registry.register(ResourcesWriteTool)?; - } - if config_guard - .security - .allowed_tools - .iter() - .any(|t| t == "file_delete") - && !read_only - { - registry.register(ResourcesDeleteTool)?; - } - - for tool in registry.all() { - if let Err(err) = validator.register_schema(tool.name(), tool.schema()) { - warn!("Failed to register schema for {}: {err}", tool.name()); - } - } - - Ok((Arc::new(registry), Arc::new(validator))) -} - -impl SessionController { - async fn create_mcp_clients( - config: Arc>, - tool_registry: Arc, - schema_validator: Arc, - credential_manager: Option>, - initial_mode: Mode, - ) -> Result<( - Arc, - HashMap>, - Vec, - )> { - let guard = config.lock().await; - let config_arc = Arc::new(guard.clone()); - let factory = McpClientFactory::new(config_arc.clone(), tool_registry, schema_validator); - - let mut missing_oauth_servers = Vec::new(); - let primary_runtime = if let Some(primary_cfg) = guard.effective_mcp_servers().first() { - let (runtime, missing) = - Self::runtime_secrets_for_server(credential_manager.clone(), primary_cfg).await?; - if missing { - missing_oauth_servers.push(primary_cfg.name.clone()); - } - runtime - } else { - None - }; - - let base_client = factory.create_with_secrets(primary_runtime).await?; - let primary: Arc = - Arc::new(PermissionLayer::new(base_client, config_arc.clone())); - primary.set_mode(initial_mode).await?; - - let mut clients: HashMap> = HashMap::new(); - if let Some(primary_cfg) = guard.effective_mcp_servers().first() { - clients.insert(primary_cfg.name.clone(), Arc::clone(&primary)); - } - - for server_cfg in guard.effective_mcp_servers().iter().skip(1) { - let (runtime, missing) = - Self::runtime_secrets_for_server(credential_manager.clone(), server_cfg).await?; - if missing { - missing_oauth_servers.push(server_cfg.name.clone()); - } - - match RemoteMcpClient::new_with_runtime(server_cfg, runtime).await { - Ok(remote) => { - let client: Arc = - Arc::new(PermissionLayer::new(Box::new(remote), config_arc.clone())); - if let Err(err) = client.set_mode(initial_mode).await { - warn!( - "Failed to initialize MCP server '{}' in mode {:?}: {}", - server_cfg.name, initial_mode, err - ); - } - clients.insert(server_cfg.name.clone(), Arc::clone(&client)); - } - Err(err) => warn!( - "Failed to initialize MCP server '{}': {}", - server_cfg.name, err - ), - } - } - - drop(guard); - - Ok((primary, clients, missing_oauth_servers)) - } - - async fn runtime_secrets_for_server( - credential_manager: Option>, - server: &McpServerConfig, - ) -> Result<(Option, bool)> { - if let Some(oauth) = &server.oauth { - if let Some(manager) = credential_manager { - match manager.load_oauth_token(&server.name).await? { - Some(token) => { - if token.access_token.trim().is_empty() || token.is_expired(Utc::now()) { - return Ok((None, true)); - } - let mut secrets = McpRuntimeSecrets::default(); - if let Some(env_name) = oauth.token_env.as_deref() { - secrets - .env_overrides - .insert(env_name.to_string(), token.access_token.clone()); - } - if matches!( - server.transport.to_ascii_lowercase().as_str(), - "http" | "websocket" - ) { - let header_value = - format!("{}{}", oauth.header_prefix(), token.access_token); - secrets.http_header = - Some((oauth.header_name().to_string(), header_value)); - } - Ok((Some(secrets), false)) - } - None => Ok((None, true)), - } - } else { - Ok((None, true)) - } - } else { - Ok((None, false)) - } - } - - pub async fn new( - provider: Arc, - config: Config, - storage: Arc, - ui: Arc, - enable_code_tools: bool, - event_tx: Option>, - ) -> Result { - let config_arc = Arc::new(TokioMutex::new(config)); - // Acquire the config asynchronously to avoid blocking the runtime. - let config_guard = config_arc.lock().await; - - let model = config_guard - .general - .default_model - .clone() - .unwrap_or_else(|| "ollama/default".to_string()); - - let mut vault_handle: Option>> = None; - let mut master_key: Option>> = None; - let mut credential_manager: Option> = None; - - if config_guard.privacy.encrypt_local_data { - let base_dir = storage - .database_path() - .parent() - .map(|p| p.to_path_buf()) - .or_else(dirs::data_local_dir) - .unwrap_or_else(|| PathBuf::from(".")); - let secure_path = base_dir.join("encrypted_data.json"); - let handle = encryption::unlock(secure_path)?; - let master = Arc::new(handle.data.master_key.clone()); - master_key = Some(master.clone()); - vault_handle = Some(Arc::new(Mutex::new(handle))); - credential_manager = Some(Arc::new(CredentialManager::new(storage.clone(), master))); - } - - let consent_manager = if let Some(ref vault) = vault_handle { - Arc::new(Mutex::new(ConsentManager::from_vault(vault))) - } else { - Arc::new(Mutex::new(ConsentManager::new())) - }; - - let approval_mode = config_guard.security.approval_mode; - let requested_code_tools = enable_code_tools; - let enable_code_tools = if matches!(approval_mode, ApprovalMode::ReadOnly) { - if requested_code_tools { - warn!("Read-only approval mode disables code tools."); - } - false - } else { - requested_code_tools - }; - - let conversation = ConversationManager::with_history_capacity( - model, - config_guard.storage.max_saved_sessions, - ); - let formatter = MessageFormatter::new( - config_guard.ui.wrap_column as usize, - config_guard.ui.role_label_mode, - ) - .with_preserve_empty(config_guard.ui.word_wrap); - let input_buffer = InputBuffer::new( - config_guard.input.history_size, - config_guard.input.multiline, - config_guard.input.tab_width, - ); - let model_manager = ModelManager::new(config_guard.general.model_cache_ttl()); - - drop(config_guard); // Release the lock before calling build_tools - - let initial_mode = if enable_code_tools { - Mode::Code - } else { - Mode::Chat - }; - - let (tool_registry, schema_validator) = build_tools( - config_arc.clone(), - ui.clone(), - enable_code_tools, - consent_manager.clone(), - credential_manager.clone(), - vault_handle.clone(), - ) - .await?; - - let (mcp_client, named_mcp_clients, missing_oauth_servers) = Self::create_mcp_clients( - config_arc.clone(), - tool_registry.clone(), - schema_validator.clone(), - credential_manager.clone(), - initial_mode, - ) - .await?; - - let usage_ledger_path = storage - .database_path() - .parent() - .map(|dir| dir.join("usage-ledger.json")) - .unwrap_or_else(|| PathBuf::from("usage-ledger.json")); - - let usage_ledger_instance = - match UsageLedger::load_or_default(usage_ledger_path.clone()).await { - Ok(ledger) => ledger, - Err(err) => { - warn!( - "Failed to load usage ledger at {}: {err}. Starting with an empty ledger.", - usage_ledger_path.display() - ); - UsageLedger::empty(usage_ledger_path) - } - }; - let usage_ledger = Arc::new(TokioMutex::new(usage_ledger_instance)); - - let mut controller = Self { - provider, - conversation, - model_manager, - input_buffer, - formatter, - config: config_arc, - consent_manager, - tool_registry, - schema_validator, - mcp_client, - named_mcp_clients, - storage, - vault: vault_handle, - master_key, - credential_manager, - ui, - enable_code_tools, - approval_mode, - plan_confirmed: true, - plan_instruction_inserted: false, - current_mode: initial_mode, - missing_oauth_servers, - event_tx, - pending_tool_requests: HashMap::new(), - stream_states: HashMap::new(), - usage_ledger, - last_compression: None, - }; - - controller.reset_plan_state(); - - Ok(controller) - } - - pub fn conversation(&self) -> &Conversation { - self.conversation.active() - } - - pub fn conversation_mut(&mut self) -> &mut ConversationManager { - &mut self.conversation - } - - pub fn last_compression(&self) -> Option { - self.last_compression.clone() - } - - pub fn input_buffer(&self) -> &InputBuffer { - &self.input_buffer - } - - pub fn input_buffer_mut(&mut self) -> &mut InputBuffer { - &mut self.input_buffer - } - - pub fn formatter(&self) -> &MessageFormatter { - &self.formatter - } - - pub async fn set_formatter_wrap_width(&mut self, width: usize) { - self.formatter.set_wrap_width(width); - } - - pub fn set_role_label_mode(&mut self, mode: RoleLabelDisplay) { - self.formatter.set_role_label_mode(mode); - } - - /// Return the configured resource references aggregated across scopes. - pub async fn configured_resources(&self) -> Vec { - let guard = self.config.lock().await; - guard.effective_mcp_resources().to_vec() - } - - /// Resolve a resource reference of the form `server:uri` (optionally prefixed with `@`). - pub async fn resolve_resource_reference(&self, reference: &str) -> Result> { - let (server, uri) = match Self::split_resource_reference(reference) { - Some(parts) => parts, - None => return Ok(None), - }; - - let resource_defined = { - let guard = self.config.lock().await; - guard.find_resource(&server, &uri).is_some() - }; - - if !resource_defined { - return Ok(None); - } - - let client = self - .named_mcp_clients - .get(&server) - .cloned() - .ok_or_else(|| { - Error::Config(format!( - "MCP server '{}' referenced by resource '{}' is not available", - server, uri - )) - })?; - - let call = McpToolCall { - name: "resources_get".to_string(), - arguments: json!({ "uri": uri, "path": uri }), - }; - let response = client.call_tool(call).await?; - if let Some(text) = extract_resource_content(&response.output) { - return Ok(Some(text)); - } - - let formatted = serde_json::to_string_pretty(&response.output) - .unwrap_or_else(|_| response.output.to_string()); - Ok(Some(formatted)) - } - - fn split_resource_reference(reference: &str) -> Option<(String, String)> { - let trimmed = reference.trim(); - let without_prefix = trimmed.strip_prefix('@').unwrap_or(trimmed); - let (server, uri) = without_prefix.split_once(':')?; - if server.is_empty() || uri.is_empty() { - return None; - } - Some((server.to_string(), uri.to_string())) - } - - async fn persist_usage_serialized(path: PathBuf, serialized: String) { - if let Some(parent) = path.parent() && let Err(err) = fs::create_dir_all(parent).await { - warn!( - "Failed to create usage ledger directory {}: {}", - parent.display(), - err - ); - return; - } - - if let Err(err) = fs::write(&path, serialized).await { - warn!("Failed to write usage ledger {}: {}", path.display(), err); - } - } - - fn parse_quota_value(value: &Value) -> Option { - match value { - Value::Number(num) => num.as_u64(), - Value::String(text) => text.trim().parse::().ok(), - _ => None, - } - } - - fn quota_from_config(config: &Config, provider: &str) -> UsageQuota { - let mut quota = UsageQuota::default(); - - if let Some(entry) = config.providers.get(provider) { - if let Some(value) = entry.extra.get("hourly_quota_tokens") { - quota.hourly_quota_tokens = Self::parse_quota_value(value); - } - if let Some(value) = entry.extra.get("weekly_quota_tokens") { - quota.weekly_quota_tokens = Self::parse_quota_value(value); - } - } - - quota - } - - pub async fn record_usage_sample( - &self, - usage: &crate::types::TokenUsage, - ) -> Option { - if usage.total_tokens == 0 { - return None; - } - - let provider_name = self.provider.name().to_string(); - if provider_name.trim().is_empty() { - return None; - } - - let quotas = { - let guard = self.config.lock().await; - Self::quota_from_config(&guard, &provider_name) - }; - - let timestamp = SystemTime::now(); - let mut serialized_payload: Option<(PathBuf, String)> = None; - - let snapshot = { - let mut ledger = self.usage_ledger.lock().await; - ledger.record(&provider_name, usage, timestamp); - let snapshot = ledger.snapshot(&provider_name, quotas, timestamp); - match ledger.serialize() { - Ok(payload) => { - serialized_payload = Some((ledger.path().to_path_buf(), payload)); - } - Err(err) => warn!("Failed to serialize usage ledger: {}", err), - } - snapshot - }; - - if let Some((path, payload)) = serialized_payload { - Self::persist_usage_serialized(path, payload).await; - } - - Some(snapshot) - } - - pub async fn current_usage_snapshot(&self) -> Option { - let provider_name = self.provider.name().to_string(); - if provider_name.trim().is_empty() { - return None; - } - - let quotas = { - let guard = self.config.lock().await; - Self::quota_from_config(&guard, &provider_name) - }; - - let now = SystemTime::now(); - let ledger = self.usage_ledger.lock().await; - Some(ledger.snapshot(&provider_name, quotas, now)) - } - - pub async fn usage_overview(&self) -> Vec { - let quota_map = { - let guard = self.config.lock().await; - guard - .providers - .keys() - .map(|name| (name.clone(), Self::quota_from_config(&guard, name))) - .collect::>() - }; - - let now = SystemTime::now(); - let mut provider_names: HashSet = quota_map.keys().cloned().collect(); - - let ledger = self.usage_ledger.lock().await; - provider_names.extend(ledger.provider_keys().cloned()); - - provider_names - .into_iter() - .map(|provider| { - let quota = quota_map.get(&provider).cloned().unwrap_or_default(); - ledger.snapshot(&provider, quota, now) - }) - .collect() - } - - // Asynchronous access to the configuration (used internally). - pub async fn config_async(&self) -> tokio::sync::MutexGuard<'_, Config> { - self.config.lock().await - } - - // Synchronous, blocking access to the configuration. This is kept for the TUI - // which expects `controller.config()` to return a reference without awaiting. - // Uses try_lock() with a brief spin to avoid block_in_place while still - // providing synchronous access for fast config reads. - pub fn config(&self) -> tokio::sync::MutexGuard<'_, Config> { - // Try to acquire the lock with a small number of retries. - // Config locks are typically held very briefly, so this should succeed quickly. - for _ in 0..100 { - if let Ok(guard) = self.config.try_lock() { - return guard; - } - std::thread::yield_now(); - } - // If we still can't get the lock, panic as this indicates a deadlock or - // the lock is being held for too long (which would be a bug). - panic!("Failed to acquire config lock after retries - possible deadlock"); - } - - // Synchronous mutable access, mirroring `config()` but allowing mutation. - pub fn config_mut(&self) -> tokio::sync::MutexGuard<'_, Config> { - for _ in 0..100 { - if let Ok(guard) = self.config.try_lock() { - return guard; - } - std::thread::yield_now(); - } - panic!("Failed to acquire config lock after retries - possible deadlock"); - } - - pub fn config_cloned(&self) -> Arc> { - self.config.clone() - } - - pub async fn compress_now(&mut self) -> Result> { - let settings = { - let guard = self.config.lock().await; - guard.chat.clone() - }; - let options = CompressionOptions::from_settings(&settings); - self.perform_compression(options, false).await - } - - pub async fn maybe_auto_compress(&mut self) -> Result> { - let settings = { - let guard = self.config.lock().await; - if !guard.chat.auto_compress { - return Ok(None); - } - guard.chat.clone() - }; - let options = CompressionOptions::from_settings(&settings); - self.perform_compression(options, true).await - } - - async fn perform_compression( - &mut self, - options: CompressionOptions, - automated: bool, - ) -> Result> { - let mut final_report = None; - let mut iterations = 0usize; - - loop { - iterations += 1; - if iterations > 4 { - break; - } - - let snapshot = self.conversation.active().clone(); - let total_tokens = estimate_tokens(&snapshot.messages); - if total_tokens <= options.trigger_tokens { - break; - } - - if snapshot.messages.len() <= options.retain_recent + 1 { - break; - } - - let split_index = snapshot - .messages - .len() - .saturating_sub(options.retain_recent); - if split_index == 0 { - break; - } - - let older_messages = &snapshot.messages[..split_index]; - if older_messages.len() < options.min_chunk_messages() { - break; - } - - if older_messages - .iter() - .all(|msg| msg.metadata.contains_key(COMPRESSION_METADATA_KEY)) - { - break; - } - - let model_used = options.resolve_model(&snapshot.model); - let summary = self - .generate_summary(older_messages, &options, &model_used) - .await; - - let summary_body = summary.trim(); - let intro = "### Conversation summary"; - let footer = if automated { - "_This summary was generated automatically to preserve context._" - } else { - "_Manual compression complete._" - }; - let content = if summary_body.is_empty() { - format!( - "{intro}\n\n_Compressed {} prior messages._\n\n{footer}", - older_messages.len() - ) - } else { - format!( - "{intro}\n\n{summary_body}\n\n_Compressed {} prior messages._\n\n{footer}", - older_messages.len() - ) - }; - - let mut summary_message = Message::system(content); - let compressed_ids: Vec = older_messages - .iter() - .map(|msg| msg.id.to_string()) - .collect(); - let summary_tokens = estimate_message_tokens(&summary_message); - let retained_tokens = estimate_tokens(&snapshot.messages[split_index..]); - let updated_tokens = summary_tokens.saturating_add(retained_tokens); - let timestamp = Utc::now(); - let metadata = json!({ - "strategy": match options.strategy { - CompressionStrategy::Provider => "provider", - CompressionStrategy::Local => "local", - }, - "automated": automated, - "compressed_message_ids": compressed_ids, - "compressed_count": older_messages.len(), - "retain_recent": options.retain_recent, - "trigger_tokens": options.trigger_tokens, - "estimated_tokens_before": total_tokens, - "model": model_used, - "estimated_tokens_after": updated_tokens, - "timestamp": timestamp.to_rfc3339(), - }); - summary_message - .metadata - .insert(COMPRESSION_METADATA_KEY.to_string(), metadata); - - let mut new_messages = - Vec::with_capacity(snapshot.messages.len() - older_messages.len() + 1); - new_messages.push(summary_message.clone()); - new_messages.extend_from_slice(&snapshot.messages[split_index..]); - self.conversation.replace_active_messages(new_messages); - let report = CompressionReport { - summary_message_id: summary_message.id, - compressed_messages: older_messages.len(), - estimated_tokens_before: total_tokens, - estimated_tokens_after: updated_tokens, - strategy: options.strategy, - model_used: model_used.clone(), - retained_recent: options.retain_recent, - automated, - timestamp, - }; - - self.last_compression = Some(report.clone()); - if automated { - info!( - "auto compression reduced transcript from {} to {} tokens (compressed {} messages)", - total_tokens, updated_tokens, report.compressed_messages - ); - } - self.emit_compression_event(report.clone()); - final_report = Some(report.clone()); - - if updated_tokens >= total_tokens { - break; - } - if updated_tokens <= options.trigger_tokens { - break; - } - - // Continue loop to attempt further reduction if needed. - } - - Ok(final_report) - } - - async fn generate_summary( - &self, - slice: &[Message], - options: &CompressionOptions, - model: &str, - ) -> String { - match options.strategy { - CompressionStrategy::Provider => { - match self.generate_provider_summary(slice, model).await { - Ok(content) if !content.trim().is_empty() => content, - Ok(_) => local_summary(slice), - Err(err) => { - warn!( - "Falling back to local compression: provider summary failed ({})", - err - ); - local_summary(slice) - } - } - } - CompressionStrategy::Local => local_summary(slice), - } - } - - async fn generate_provider_summary(&self, slice: &[Message], model: &str) -> Result { - let mut prompt_messages = Vec::new(); - prompt_messages.push(Message::system("You are Owlen's transcript compactor. Summarize the provided conversation excerpt into concise markdown with sections for context, decisions, outstanding tasks, and facts that must be preserved. Avoid referring to removed content explicitly.".to_string())); - let transcript = build_transcript(slice); - prompt_messages.push(Message::user(transcript)); - - let request = ChatRequest { - model: model.to_string(), - messages: prompt_messages, - parameters: ChatParameters::default(), - tools: None, - }; - - let response = self.provider.send_prompt(request).await?; - Ok(response.message.content) - } - - fn emit_compression_event(&self, report: CompressionReport) { - if let Some(tx) = &self.event_tx { - let _ = tx.send(ControllerEvent::CompressionCompleted { report }); - } - } - - pub async fn reload_mcp_clients(&mut self) -> Result<()> { - let (primary, named, missing) = Self::create_mcp_clients( - self.config.clone(), - self.tool_registry.clone(), - self.schema_validator.clone(), - self.credential_manager.clone(), - self.current_mode, - ) - .await?; - self.mcp_client = primary; - self.named_mcp_clients = named; - self.missing_oauth_servers = missing; - Ok(()) - } - - pub fn grant_consent(&self, tool_name: &str, data_types: Vec, endpoints: Vec) { - let mut consent = self - .consent_manager - .lock() - .expect("Consent manager mutex poisoned"); - consent.grant_consent(tool_name, data_types, endpoints); - - let Some(vault) = &self.vault else { - return; - }; - if let Err(e) = consent.persist_to_vault(vault) { - eprintln!("Warning: Failed to persist consent to vault: {}", e); - } - } - - pub fn grant_consent_with_scope( - &self, - tool_name: &str, - data_types: Vec, - endpoints: Vec, - scope: crate::consent::ConsentScope, - ) { - let mut consent = self - .consent_manager - .lock() - .expect("Consent manager mutex poisoned"); - let is_permanent = matches!(scope, crate::consent::ConsentScope::Permanent); - consent.grant_consent_with_scope(tool_name, data_types, endpoints, scope); - - // Only persist to vault for permanent consent - if !is_permanent { - return; - } - let Some(vault) = &self.vault else { - return; - }; - if let Err(e) = consent.persist_to_vault(vault) { - eprintln!("Warning: Failed to persist consent to vault: {}", e); - } - } - - pub fn check_tools_consent_needed( - &self, - tool_calls: &[ToolCall], - ) -> Vec<(String, Vec, Vec)> { - let consent = self - .consent_manager - .lock() - .expect("Consent manager mutex poisoned"); - let mut needs_consent = Vec::new(); - let mut seen_tools = std::collections::HashSet::new(); - - for tool_call in tool_calls { - let canonical = canonical_tool_name(tool_call.name.as_str()).to_string(); - if seen_tools.contains(&canonical) { - continue; - } - seen_tools.insert(canonical.clone()); - - let (data_types, endpoints) = match canonical.as_str() { - WEB_SEARCH_TOOL_NAME => ( - vec!["search query".to_string()], - vec!["cloud provider".to_string()], - ), - "code_exec" => ( - vec!["code to execute".to_string()], - vec!["local sandbox".to_string()], - ), - "resources_write" | "file_write" => ( - vec!["file paths".to_string(), "file content".to_string()], - vec!["local filesystem".to_string()], - ), - "resources_delete" | "file_delete" => ( - vec!["file paths".to_string()], - vec!["local filesystem".to_string()], - ), - _ => (vec![], vec![]), - }; - - if let Some((tool_name, dt, ep)) = - consent.check_if_consent_needed(&tool_call.name, data_types, endpoints) - { - needs_consent.push((tool_name, dt, ep)); - } - } - - needs_consent - } - - pub async fn save_active_session( - &self, - name: Option, - description: Option, - ) -> Result { - self.conversation - .save_active_with_description(&self.storage, name, description) - .await - } - - pub async fn save_active_session_simple(&self, name: Option) -> Result { - self.conversation.save_active(&self.storage, name).await - } - - pub async fn load_saved_session(&mut self, id: Uuid) -> Result<()> { - self.conversation.load_saved(&self.storage, id).await - } - - pub async fn list_saved_sessions(&self) -> Result> { - ConversationManager::list_saved_sessions(&self.storage).await - } - - pub async fn delete_session(&self, id: Uuid) -> Result<()> { - self.storage.delete_session(id).await - } - - pub async fn clear_secure_data(&self) -> Result<()> { - // ... (implementation remains the same) - Ok(()) - } - - pub fn persist_consent(&self) -> Result<()> { - // ... (implementation remains the same) - Ok(()) - } - - pub async fn set_tool_enabled(&mut self, tool: &str, enabled: bool) -> Result<()> { - { - let mut config = self.config.lock().await; - let canonical = canonical_tool_name(tool); - match canonical { - WEB_SEARCH_TOOL_NAME => { - config.tools.web_search.enabled = enabled; - config.privacy.enable_remote_search = enabled; - } - "code_exec" => config.tools.code_exec.enabled = enabled, - _ => return Err(Error::InvalidInput(format!("Unknown tool: {tool}"))), - } - } - self.rebuild_tools().await - } - - pub fn consent_manager(&self) -> Arc> { - self.consent_manager.clone() - } - - pub fn tool_registry(&self) -> Arc { - self.tool_registry.clone() - } - - pub fn schema_validator(&self) -> Arc { - self.schema_validator.clone() - } - - pub fn credential_manager(&self) -> Option> { - self.credential_manager.clone() - } - - pub fn pending_oauth_servers(&self) -> Vec { - self.missing_oauth_servers.clone() - } - - pub async fn start_oauth_device_flow(&self, server: &str) -> Result { - let oauth_config = { - let config = self.config.lock().await; - let server_cfg = config - .effective_mcp_servers() - .iter() - .find(|entry| entry.name == server) - .ok_or_else(|| { - Error::Config(format!("No MCP server named '{server}' is configured")) - })?; - server_cfg.oauth.clone().ok_or_else(|| { - Error::Config(format!( - "MCP server '{server}' does not define an OAuth configuration" - )) - })? - }; - - let client = OAuthClient::new(oauth_config)?; - client.start_device_authorization().await - } - - pub async fn poll_oauth_device_flow( - &mut self, - server: &str, - authorization: &DeviceAuthorization, - ) -> Result { - let oauth_config = { - let config = self.config.lock().await; - let server_cfg = config - .effective_mcp_servers() - .iter() - .find(|entry| entry.name == server) - .ok_or_else(|| { - Error::Config(format!("No MCP server named '{server}' is configured")) - })?; - server_cfg.oauth.clone().ok_or_else(|| { - Error::Config(format!( - "MCP server '{server}' does not define an OAuth configuration" - )) - })? - }; - - let client = OAuthClient::new(oauth_config)?; - match client.poll_device_token(authorization).await? { - DevicePollState::Pending { retry_in } => Ok(DevicePollState::Pending { retry_in }), - DevicePollState::Complete(token) => { - let manager = self.credential_manager.as_ref().cloned().ok_or_else(|| { - Error::Config( - "OAuth token storage requires encrypted local data; set \ - privacy.encrypt_local_data = true in the configuration." - .to_string(), - ) - })?; - - manager.store_oauth_token(server, &token).await?; - self.missing_oauth_servers.retain(|entry| entry != server); - - Ok(DevicePollState::Complete(token)) - } - } - } - - pub async fn list_mcp_tools(&self) -> Vec<(String, crate::mcp::McpToolDescriptor)> { - let mut entries = Vec::new(); - for (server, client) in self.named_mcp_clients.iter() { - let server_name = server.clone(); - let client = Arc::clone(client); - match client.list_tools().await { - Ok(tools) => { - for descriptor in tools { - entries.push((server_name.clone(), descriptor)); - } - } - Err(err) => { - warn!( - "Failed to list tools for MCP server '{}': {}", - server_name, err - ); - } - } - } - entries - } - - pub async fn call_mcp_tool( - &self, - server: &str, - tool: &str, - arguments: Value, - ) -> Result { - let client = self.named_mcp_clients.get(server).cloned().ok_or_else(|| { - Error::Config(format!("No MCP server named '{}' is registered", server)) - })?; - client - .call_tool(McpToolCall { - name: tool.to_string(), - arguments, - }) - .await - } - - pub fn mcp_server(&self) -> crate::mcp::McpServer { - crate::mcp::McpServer::new(self.tool_registry(), self.schema_validator()) - } - - pub fn storage(&self) -> Arc { - self.storage.clone() - } - - pub fn master_key(&self) -> Option>> { - self.master_key.as_ref().map(Arc::clone) - } - - pub fn vault(&self) -> Option>> { - self.vault.as_ref().map(Arc::clone) - } - - pub async fn read_file(&self, path: &str) -> Result { - let call = McpToolCall { - name: "resources_get".to_string(), - arguments: serde_json::json!({ "path": path }), - }; - match self.mcp_client.call_tool(call).await { - Ok(response) => { - if let Some(text) = extract_resource_content(&response.output) { - return Ok(text); - } - - let formatted = serde_json::to_string_pretty(&response.output) - .unwrap_or_else(|_| response.output.to_string()); - Ok(formatted) - } - Err(err) => { - log::warn!("MCP file read failed ({}); falling back to local read", err); - let content = std::fs::read_to_string(path)?; - Ok(content) - } - } - } - - pub async fn read_file_with_tools(&self, path: &str) -> Result { - if !self.enable_code_tools { - return Err(Error::InvalidInput( - "Code tools are disabled in chat mode. Run `:mode code` to switch.".to_string(), - )); - } - - let call = McpToolCall { - name: "resources_get".to_string(), - arguments: serde_json::json!({ "path": path }), - }; - - let response = self.mcp_client.call_tool(call).await?; - if let Some(text) = extract_resource_content(&response.output) { - Ok(text) - } else { - let formatted = serde_json::to_string_pretty(&response.output) - .unwrap_or_else(|_| response.output.to_string()); - Ok(formatted) - } - } - - pub fn code_tools_enabled(&self) -> bool { - self.enable_code_tools - } - - pub async fn set_code_tools_enabled(&mut self, enabled: bool) -> Result<()> { - if matches!(self.approval_mode, ApprovalMode::ReadOnly) && enabled { - warn!("Read-only approval mode prevents enabling code tools; ignoring request."); - self.enable_code_tools = false; - return Ok(()); - } - if self.enable_code_tools == enabled { - return Ok(()); - } - - self.enable_code_tools = enabled; - self.rebuild_tools().await - } - - fn reset_plan_state(&mut self) { - self.plan_instruction_inserted = false; - match self.approval_mode { - ApprovalMode::PlanFirst => { - self.plan_confirmed = false; - self.ensure_plan_prompt(); - } - _ => { - self.plan_confirmed = true; - } - } - } - - fn ensure_plan_prompt(&mut self) { - if !matches!(self.approval_mode, ApprovalMode::PlanFirst) { - return; - } - if self.plan_instruction_inserted { - return; - } - let prompt = "You are operating in plan-first approval mode. Outline a concise plan before using any tools or modifying files, and wait for the user to send '#approve' before proceeding."; - self.conversation.push_system_message(prompt); - self.plan_instruction_inserted = true; - } - - pub async fn set_operating_mode(&mut self, mode: Mode) -> Result<()> { - if matches!(self.approval_mode, ApprovalMode::ReadOnly) && matches!(mode, Mode::Code) { - warn!( - "Read-only approval mode prevents switching to code mode; remaining in chat mode." - ); - self.current_mode = Mode::Chat; - self.set_code_tools_enabled(false).await?; - return Ok(()); - } - - self.current_mode = mode; - let enable_code_tools = matches!(mode, Mode::Code); - self.set_code_tools_enabled(enable_code_tools).await?; - self.mcp_client.set_mode(self.current_mode).await - } - - pub async fn list_dir(&self, path: &str) -> Result> { - let call = McpToolCall { - name: "resources_list".to_string(), - arguments: serde_json::json!({ "path": path }), - }; - match self.mcp_client.call_tool(call).await { - Ok(response) => { - let content: Vec = serde_json::from_value(response.output)?; - Ok(content) - } - Err(err) => { - log::warn!( - "MCP directory list failed ({}); falling back to local list", - err - ); - let mut entries = Vec::new(); - for entry in std::fs::read_dir(path)? { - let entry = entry?; - entries.push(entry.file_name().to_string_lossy().to_string()); - } - Ok(entries) - } - } - } - - pub async fn write_file(&self, path: &str, content: &str) -> Result<()> { - let call = McpToolCall { - name: "resources_write".to_string(), - arguments: serde_json::json!({ "path": path, "content": content }), - }; - match self.mcp_client.call_tool(call).await { - Ok(_) => Ok(()), - Err(err) => { - log::warn!( - "MCP file write failed ({}); falling back to local write", - err - ); - // Ensure parent directory exists - if let Some(parent) = std::path::Path::new(path).parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, content)?; - Ok(()) - } - } - } - - pub async fn delete_file(&self, path: &str) -> Result<()> { - let call = McpToolCall { - name: "resources_delete".to_string(), - arguments: serde_json::json!({ "path": path }), - }; - match self.mcp_client.call_tool(call).await { - Ok(_) => Ok(()), - Err(err) => { - log::warn!( - "MCP file delete failed ({}); falling back to local delete", - err - ); - std::fs::remove_file(path)?; - Ok(()) - } - } - } - - async fn rebuild_tools(&mut self) -> Result<()> { - let (registry, validator) = build_tools( - self.config.clone(), - self.ui.clone(), - self.enable_code_tools, - self.consent_manager.clone(), - self.credential_manager.clone(), - self.vault.clone(), - ) - .await?; - self.tool_registry = registry; - self.schema_validator = validator; - - // Recreate MCP client with permission layer - let config = self.config.lock().await; - let factory = McpClientFactory::new( - Arc::new(config.clone()), - self.tool_registry.clone(), - self.schema_validator.clone(), - ); - let base_client = factory.create().await?; - let permission_client = PermissionLayer::new(base_client, Arc::new(config.clone())); - let client = Arc::new(permission_client); - client.set_mode(self.current_mode).await?; - self.mcp_client = client; - - Ok(()) - } - - pub fn selected_model(&self) -> &str { - &self.conversation.active().model - } - - pub async fn set_model(&mut self, model: String) { - self.conversation.set_model(model.clone()); - let mut config = self.config.lock().await; - config.general.default_model = Some(model); - } - - pub async fn models(&self, force_refresh: bool) -> Result> { - self.model_manager - .get_or_refresh(force_refresh, || async { - self.provider.list_models().await - }) - .await - } - - fn as_ollama(&self) -> Option<&OllamaProvider> { - self.provider - .as_ref() - .as_any() - .downcast_ref::() - } - - pub async fn model_details( - &self, - model_name: &str, - force_refresh: bool, - ) -> Result { - if let Some(ollama) = self.as_ollama() { - if force_refresh { - ollama.refresh_model_info(model_name).await - } else { - ollama.get_model_info(model_name).await - } - } else { - Err(Error::NotImplemented(format!( - "Provider '{}' does not expose model inspection", - self.provider.name() - ))) - } - } - - pub async fn all_model_details(&self, force_refresh: bool) -> Result> { - if let Some(ollama) = self.as_ollama() { - if force_refresh { - ollama.clear_model_info_cache().await; - } - ollama.get_all_models_info().await - } else { - Err(Error::NotImplemented(format!( - "Provider '{}' does not expose model inspection", - self.provider.name() - ))) - } - } - - pub async fn cached_model_details(&self) -> Vec { - if let Some(ollama) = self.as_ollama() { - ollama.cached_model_info().await - } else { - Vec::new() - } - } - - pub async fn invalidate_model_details(&self, model_name: &str) { - if let Some(ollama) = self.as_ollama() { - ollama.invalidate_model_info(model_name).await; - } - } - - pub async fn clear_model_details_cache(&self) { - if let Some(ollama) = self.as_ollama() { - ollama.clear_model_info_cache().await; - } - } - - pub async fn ensure_default_model(&mut self, models: &[ModelInfo]) { - let mut config = self.config.lock().await; - if let Some(default) = config.general.default_model.clone() { - if models.iter().any(|m| m.id == default || m.name == default) { - self.conversation.set_model(default.clone()); - config.general.default_model = Some(default); - } - } else if let Some(model) = models.first() { - self.conversation.set_model(model.id.clone()); - config.general.default_model = Some(model.id.clone()); - } - } - - pub async fn switch_provider(&mut self, provider: Arc) -> Result<()> { - self.provider = provider; - self.model_manager.invalidate().await; - Ok(()) - } - - /// Expose the underlying LLM provider. - pub fn provider(&self) -> Arc { - self.provider.clone() - } - - pub async fn send_message( - &mut self, - content: String, - mut parameters: ChatParameters, - ) -> Result { - let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream }; - parameters.stream = streaming; - - if matches!(self.approval_mode, ApprovalMode::PlanFirst) { - self.ensure_plan_prompt(); - let trimmed = content.trim(); - let normalized = trimmed.to_ascii_lowercase(); - if matches!( - normalized.as_str(), - "#approve" | ":approve" | "/approve" | "approve" | "approve plan" - ) { - self.conversation.push_user_message(trimmed.to_string()); - let _ = self.maybe_auto_compress().await?; - if !self.plan_confirmed { - self.plan_confirmed = true; - self.conversation - .push_system_message("Plan approved by the operator. Tool access enabled."); - } - - let ack = Message::assistant( - "Plan approved. Ready to continue with execution.".to_string(), - ); - self.conversation.push_message(ack.clone()); - return Ok(SessionOutcome::Complete(ChatResponse { - message: ack, - usage: None, - is_streaming: false, - is_final: true, - })); - } - } - - self.conversation.push_user_message(content); - let _ = self.maybe_auto_compress().await?; - self.send_request_with_current_conversation(parameters) - .await - } - - pub async fn send_request_with_current_conversation( - &mut self, - mut parameters: ChatParameters, - ) -> Result { - let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream }; - parameters.stream = streaming; - - if matches!(self.approval_mode, ApprovalMode::PlanFirst) { - self.ensure_plan_prompt(); - } - - let active_model = self.conversation.active().model.clone(); - let registry_tools = self.tool_registry.all(); - let mut include_tools = !registry_tools.is_empty(); - - if matches!(self.approval_mode, ApprovalMode::PlanFirst) && !self.plan_confirmed { - include_tools = false; - } - - if include_tools { - let cached_support = self.model_manager.select(&active_model).await; - let supports_tools = match cached_support { - Some(info) => info.supports_tools, - None => match self.models(false).await { - Ok(models) => models - .iter() - .find(|model| model.id == active_model || model.name == active_model) - .map(|model| model.supports_tools) - .unwrap_or(true), - Err(err) => { - warn!( - "Unable to resolve tool support for model '{}': {}. Assuming tools are supported.", - active_model, err - ); - true - } - }, - }; - - if !supports_tools { - include_tools = false; - debug!( - "Disabling tools for model '{}' because it does not advertise tool support.", - active_model - ); - } - } - - let tools = if include_tools { - Some( - registry_tools - .iter() - .map(|tool| crate::mcp::McpToolDescriptor { - name: tool.name().to_string(), - description: tool.description().to_string(), - input_schema: tool.schema(), - requires_network: tool.requires_network(), - requires_filesystem: tool.requires_filesystem(), - }) - .collect(), - ) - } else { - None - }; - - let mut request = ChatRequest { - model: active_model, - messages: self.conversation.active().messages.clone(), - parameters: parameters.clone(), - tools: tools.clone(), - }; - - if !streaming { - const MAX_TOOL_ITERATIONS: usize = 5; - for _iteration in 0..MAX_TOOL_ITERATIONS { - match self.provider.send_prompt(request.clone()).await { - Ok(response) => { - if response.message.has_tool_calls() { - self.conversation.push_message(response.message.clone()); - if let Some(tool_calls) = &response.message.tool_calls { - for tool_call in tool_calls { - let mcp_tool_call = McpToolCall { - name: tool_call.name.clone(), - arguments: tool_call.arguments.clone(), - }; - let tool_result = - self.mcp_client.call_tool(mcp_tool_call).await; - let tool_response_content = match tool_result { - Ok(result) => serde_json::to_string_pretty(&result.output) - .unwrap_or_else(|_| { - "Tool execution succeeded".to_string() - }), - Err(e) => format!("Tool execution failed: {}", e), - }; - let tool_msg = - Message::tool(tool_call.id.clone(), tool_response_content); - self.conversation.push_message(tool_msg); - } - } - request.messages = self.conversation.active().messages.clone(); - continue; - } else { - if let Some(usage) = response.usage.as_ref() { - let _ = self.record_usage_sample(usage).await; - } - self.conversation.push_message(response.message.clone()); - let _ = self.maybe_auto_compress().await?; - return Ok(SessionOutcome::Complete(response)); - } - } - Err(err) => { - self.conversation - .push_assistant_message(format!("Error: {}", err)); - return Err(err); - } - } - } - self.conversation - .push_assistant_message("Maximum tool execution iterations reached".to_string()); - return Err(crate::Error::Provider(anyhow::anyhow!( - "Maximum tool execution iterations reached" - ))); - } - - match self.provider.stream_prompt(request).await { - Ok(stream) => { - let response_id = self.conversation.start_streaming_response(); - self.stream_states - .insert(response_id, StreamingMessageState::new()); - Ok(SessionOutcome::Streaming { - response_id, - stream, - }) - } - Err(err) => { - self.conversation - .push_assistant_message(format!("Error starting stream: {}", err)); - Err(err) - } - } - } - - pub fn mark_stream_placeholder(&mut self, message_id: Uuid, text: &str) -> Result<()> { - self.conversation - .set_stream_placeholder(message_id, text.to_string()) - } - - pub fn apply_stream_chunk(&mut self, message_id: Uuid, chunk: &ChatResponse) -> Result<()> { - let state = self.stream_states.entry(message_id).or_default(); - - let diff = state.ingest(chunk); - - if let Some(text_delta) = diff.text { - match text_delta.mode { - TextDeltaKind::Append => { - self.conversation.append_stream_chunk( - message_id, - &text_delta.content, - chunk.is_final, - )?; - } - TextDeltaKind::Replace => { - self.conversation.set_stream_content( - message_id, - text_delta.content, - chunk.is_final, - )?; - } - } - } else if chunk.is_final { - self.conversation - .append_stream_chunk(message_id, "", true)?; - } - - if let Some(tool_calls) = diff.tool_calls { - self.conversation - .set_tool_calls_on_message(message_id, tool_calls)?; - } - - if chunk.is_final { - state.mark_finished(); - self.stream_states.remove(&message_id); - } - - Ok(()) - } - - pub fn check_streaming_tool_calls(&mut self, message_id: Uuid) -> Option> { - let maybe_calls = self - .conversation - .active() - .messages - .iter() - .find(|m| m.id == message_id) - .and_then(|m| m.tool_calls.clone()) - .filter(|calls| !calls.is_empty()); - - let calls = maybe_calls?; - - if !self - .pending_tool_requests - .values() - .any(|pending| pending.message_id == message_id) - && let Some((tool_name, data_types, endpoints)) = - self.check_tools_consent_needed(&calls).into_iter().next() - { - let request_id = Uuid::new_v4(); - let pending = PendingToolRequest { - message_id, - tool_name: tool_name.clone(), - data_types: data_types.clone(), - endpoints: endpoints.clone(), - tool_calls: calls.clone(), - }; - self.pending_tool_requests.insert(request_id, pending); - - if let Some(tx) = &self.event_tx { - let _ = tx.send(ControllerEvent::ToolRequested { - request_id, - message_id, - tool_name, - data_types, - endpoints, - tool_calls: calls.clone(), - }); - } - } - - Some(calls) - } - - pub fn resolve_tool_consent( - &mut self, - request_id: Uuid, - scope: ConsentScope, - ) -> Result { - let pending = self - .pending_tool_requests - .remove(&request_id) - .ok_or_else(|| { - Error::InvalidInput(format!("Unknown tool consent request: {}", request_id)) - })?; - - let PendingToolRequest { - message_id, - tool_name, - data_types, - endpoints, - tool_calls, - .. - } = pending; - - if !matches!(scope, ConsentScope::Denied) { - self.grant_consent_with_scope(&tool_name, data_types, endpoints, scope.clone()); - } - - Ok(ToolConsentResolution { - request_id, - message_id, - tool_name, - scope, - tool_calls, - }) - } - - pub fn cancel_stream(&mut self, message_id: Uuid, notice: &str) -> Result<()> { - self.stream_states.remove(&message_id); - self.conversation - .cancel_stream(message_id, notice.to_string()) - } - - pub async fn execute_streaming_tools( - &mut self, - _message_id: Uuid, - tool_calls: Vec, - ) -> Result { - for tool_call in &tool_calls { - let mcp_tool_call = McpToolCall { - name: tool_call.name.clone(), - arguments: tool_call.arguments.clone(), - }; - let tool_result = self.mcp_client.call_tool(mcp_tool_call).await; - let tool_response_content = match tool_result { - Ok(result) => serde_json::to_string_pretty(&result.output) - .unwrap_or_else(|_| "Tool execution succeeded".to_string()), - Err(e) => format!("Tool execution failed: {}", e), - }; - let tool_msg = Message::tool(tool_call.id.clone(), tool_response_content); - self.conversation.push_message(tool_msg); - } - let parameters = ChatParameters { - stream: self.config.lock().await.general.enable_streaming, - ..Default::default() - }; - self.send_request_with_current_conversation(parameters) - .await - } - - pub fn history(&self) -> Vec { - self.conversation.history().cloned().collect() - } - - pub fn start_new_conversation(&mut self, model: Option, name: Option) { - self.conversation.start_new(model, name); - self.stream_states.clear(); - self.reset_plan_state(); - } - - pub fn clear(&mut self) { - self.conversation.clear(); - self.stream_states.clear(); - self.reset_plan_state(); - } - - pub async fn generate_conversation_description(&self) -> Result { - // ... (implementation remains the same) - Ok("Empty conversation".to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Provider; - use crate::config::{Config, McpMode, McpOAuthConfig, McpServerConfig}; - use crate::llm::test_utils::MockProvider; - use crate::storage::StorageManager; - use crate::ui::NoOpUiController; - use chrono::Utc; - use httpmock::prelude::*; - use serde_json::json; - use std::collections::HashMap; - use std::sync::Arc; - use tempfile::tempdir; - - fn make_response( - text: &str, - tool_calls: Option>, - is_final: bool, - ) -> ChatResponse { - let mut message = Message::assistant(text.to_string()); - message.tool_calls = tool_calls; - ChatResponse { - message, - usage: None, - is_streaming: true, - is_final, - } - } - - fn make_tool_call(id: &str, name: &str) -> ToolCall { - ToolCall { - id: id.to_string(), - name: name.to_string(), - arguments: serde_json::json!({}), - } - } - - const SERVER_NAME: &str = "oauth-test"; - - fn build_oauth_config(server: &MockServer) -> McpOAuthConfig { - McpOAuthConfig { - client_id: "owlen-client".to_string(), - client_secret: None, - authorize_url: server.url("/authorize"), - token_url: server.url("/token"), - device_authorization_url: Some(server.url("/device")), - redirect_url: None, - scopes: vec!["repo".to_string()], - token_env: Some("OAUTH_TOKEN".to_string()), - header: Some("Authorization".to_string()), - header_prefix: Some("Bearer ".to_string()), - } - } - - fn build_config(server: &MockServer) -> Config { - let mut config = Config::default(); - config.mcp.mode = McpMode::LocalOnly; - let oauth = build_oauth_config(server); - - let mut env = HashMap::new(); - env.insert("OWLEN_ENV".to_string(), "test".to_string()); - - config.mcp_servers = vec![McpServerConfig { - name: SERVER_NAME.to_string(), - command: server.url("/mcp"), - args: Vec::new(), - transport: "http".to_string(), - env, - oauth: Some(oauth), - rpc_timeout_secs: None, - }]; - - config.refresh_mcp_servers(None).unwrap(); - config - } - - #[test] - fn streaming_state_tracks_text_deltas() { - let mut state = StreamingMessageState::new(); - - let diff = state.ingest(&make_response("Hello", None, false)); - let first = diff.text.expect("text diff"); - assert_eq!(first.content, "Hello"); - assert_eq!(first.mode, TextDeltaKind::Append); - - let diff = state.ingest(&make_response("Hello world", None, false)); - let second = diff.text.expect("second diff"); - assert_eq!(second.content, " world"); - assert_eq!(second.mode, TextDeltaKind::Append); - - let diff = state.ingest(&make_response("Hi", None, false)); - let third = diff.text.expect("third diff"); - assert_eq!(third.content, "Hi"); - assert_eq!(third.mode, TextDeltaKind::Replace); - } - - #[test] - fn streaming_state_detects_tool_call_changes() { - let mut state = StreamingMessageState::new(); - let tool = make_tool_call("call-1", "web_search"); - - let diff = state.ingest(&make_response("", Some(vec![tool.clone()]), false)); - let calls = diff.tool_calls.expect("initial tool call"); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].name, "web_search"); - - let diff = state.ingest(&make_response("", Some(vec![tool.clone()]), false)); - assert!( - diff.tool_calls.is_none(), - "duplicate tool call should not emit" - ); - - let diff = state.ingest(&make_response("", Some(vec![]), false)); - let cleared = diff.tool_calls.expect("clearing tool calls"); - assert!(cleared.is_empty()); - } - - async fn build_session(server: &MockServer) -> (SessionController, tempfile::TempDir) { - let temp_dir = tempdir().expect("tempdir"); - let storage_path = temp_dir.path().join("owlen.db"); - let storage = Arc::new( - StorageManager::with_database_path(storage_path) - .await - .expect("storage"), - ); - - let config = build_config(server); - let provider: Arc = Arc::new(MockProvider::default()) as Arc; - let ui = Arc::new(NoOpUiController); - - let session = SessionController::new(provider, config, storage, ui, false, None) - .await - .expect("session"); - - (session, temp_dir) - } - - #[tokio::test] - async fn start_oauth_device_flow_returns_details() { - let server = MockServer::start_async().await; - let device = server - .mock_async(|when, then| { - when.method(POST).path("/device"); - then.status(200) - .header("content-type", "application/json") - .json_body(json!({ - "device_code": "device-abc", - "user_code": "ABCD-1234", - "verification_uri": "https://example.test/activate", - "verification_uri_complete": "https://example.test/activate?user_code=ABCD-1234", - "expires_in": 600, - "interval": 5, - "message": "Enter the code to continue." - })); - }) - .await; - - let (session, _dir) = build_session(&server).await; - let authorization = session - .start_oauth_device_flow(SERVER_NAME) - .await - .expect("device flow"); - - assert_eq!(authorization.user_code, "ABCD-1234"); - assert_eq!( - authorization.verification_uri_complete.as_deref(), - Some("https://example.test/activate?user_code=ABCD-1234") - ); - assert!(authorization.expires_at > Utc::now()); - device.assert_async().await; - } - - #[tokio::test] - async fn poll_oauth_device_flow_stores_token_and_updates_state() { - let server = MockServer::start_async().await; - - let device = server - .mock_async(|when, then| { - when.method(POST).path("/device"); - then.status(200) - .header("content-type", "application/json") - .json_body(json!({ - "device_code": "device-xyz", - "user_code": "WXYZ-9999", - "verification_uri": "https://example.test/activate", - "verification_uri_complete": "https://example.test/activate?user_code=WXYZ-9999", - "expires_in": 600, - "interval": 5 - })); - }) - .await; - - let token = server - .mock_async(|when, then| { - when.method(POST) - .path("/token") - .body_contains("device_code=device-xyz"); - then.status(200) - .header("content-type", "application/json") - .json_body(json!({ - "access_token": "new-access-token", - "refresh_token": "refresh-token", - "expires_in": 3600, - "token_type": "Bearer" - })); - }) - .await; - - let (mut session, _dir) = build_session(&server).await; - assert_eq!(session.pending_oauth_servers(), vec![SERVER_NAME]); - - let authorization = session - .start_oauth_device_flow(SERVER_NAME) - .await - .expect("device flow"); - - match session - .poll_oauth_device_flow(SERVER_NAME, &authorization) - .await - .expect("token poll") - { - DevicePollState::Complete(token_info) => { - assert_eq!(token_info.access_token, "new-access-token"); - assert_eq!(token_info.refresh_token.as_deref(), Some("refresh-token")); - } - other => panic!("expected token completion, got {other:?}"), - } - - assert!( - session - .pending_oauth_servers() - .iter() - .all(|entry| entry != SERVER_NAME), - "server should be removed from pending list" - ); - - let stored = session - .credential_manager() - .expect("credential manager") - .load_oauth_token(SERVER_NAME) - .await - .expect("load token") - .expect("token present"); - - assert_eq!(stored.access_token, "new-access-token"); - assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token")); - - device.assert_async().await; - token.assert_async().await; - } -} diff --git a/crates/owlen-core/src/state/mod.rs b/crates/owlen-core/src/state/mod.rs deleted file mode 100644 index 2107e06..0000000 --- a/crates/owlen-core/src/state/mod.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Shared application state types used across TUI frontends. - -use std::fmt; - -/// High-level application state reported by the UI loop. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum AppState { - Running, - Quit, -} - -/// Vim-style input modes supported by the TUI. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum InputMode { - Normal, - Editing, - ProviderSelection, - ModelSelection, - Help, - Visual, - Command, - SessionBrowser, - ThemeBrowser, - RepoSearch, - SymbolSearch, -} - -impl fmt::Display for InputMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let label = match self { - InputMode::Normal => "Normal", - InputMode::Editing => "Editing", - InputMode::ModelSelection => "Model", - InputMode::ProviderSelection => "Provider", - InputMode::Help => "Help", - InputMode::Visual => "Visual", - InputMode::Command => "Command", - InputMode::SessionBrowser => "Sessions", - InputMode::ThemeBrowser => "Themes", - InputMode::RepoSearch => "Search", - InputMode::SymbolSearch => "Symbols", - }; - f.write_str(label) - } -} - -/// Represents which panel is currently focused in the TUI layout. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum FocusedPanel { - Files, - Chat, - Thinking, - Input, - Code, -} - -/// Auto-scroll state manager for scrollable panels. -#[derive(Debug, Clone)] -pub struct AutoScroll { - pub scroll: usize, - pub content_len: usize, - pub stick_to_bottom: bool, -} - -impl Default for AutoScroll { - fn default() -> Self { - Self { - scroll: 0, - content_len: 0, - stick_to_bottom: true, - } - } -} - -impl AutoScroll { - /// Update scroll position based on viewport height. - pub fn on_viewport(&mut self, viewport_h: usize) { - let max = self.content_len.saturating_sub(viewport_h); - if self.stick_to_bottom { - self.scroll = max; - } else { - self.scroll = self.scroll.min(max); - } - } - - /// Handle user scroll input. - pub fn on_user_scroll(&mut self, delta: isize, viewport_h: usize) { - let max = self.content_len.saturating_sub(viewport_h) as isize; - let s = (self.scroll as isize + delta).clamp(0, max) as usize; - self.scroll = s; - self.stick_to_bottom = s as isize == max; - } - - pub fn scroll_half_page_down(&mut self, viewport_h: usize) { - let delta = (viewport_h / 2) as isize; - self.on_user_scroll(delta, viewport_h); - } - - pub fn scroll_half_page_up(&mut self, viewport_h: usize) { - let delta = -((viewport_h / 2) as isize); - self.on_user_scroll(delta, viewport_h); - } - - pub fn scroll_full_page_down(&mut self, viewport_h: usize) { - let delta = viewport_h as isize; - self.on_user_scroll(delta, viewport_h); - } - - pub fn scroll_full_page_up(&mut self, viewport_h: usize) { - let delta = -(viewport_h as isize); - self.on_user_scroll(delta, viewport_h); - } - - pub fn jump_to_top(&mut self) { - self.scroll = 0; - self.stick_to_bottom = false; - } - - pub fn jump_to_bottom(&mut self, viewport_h: usize) { - self.stick_to_bottom = true; - self.on_viewport(viewport_h); - } -} - -/// Visual selection state for text selection. -#[derive(Debug, Clone, Default)] -pub struct VisualSelection { - pub start: Option<(usize, usize)>, - pub end: Option<(usize, usize)>, -} - -impl VisualSelection { - pub fn new() -> Self { - Self::default() - } - - pub fn start_at(&mut self, pos: (usize, usize)) { - self.start = Some(pos); - self.end = Some(pos); - } - - pub fn extend_to(&mut self, pos: (usize, usize)) { - self.end = Some(pos); - } - - pub fn clear(&mut self) { - self.start = None; - self.end = None; - } - - pub fn is_active(&self) -> bool { - self.start.is_some() && self.end.is_some() - } - - pub fn get_normalized(&self) -> Option<((usize, usize), (usize, usize))> { - if let (Some(s), Some(e)) = (self.start, self.end) { - if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) { - Some((s, e)) - } else { - Some((e, s)) - } - } else { - None - } - } -} - -/// Cursor position helper for navigating scrollable content. -#[derive(Debug, Clone, Copy, Default)] -pub struct CursorPosition { - pub row: usize, - pub col: usize, -} - -impl CursorPosition { - pub fn new(row: usize, col: usize) -> Self { - Self { row, col } - } - - pub fn move_up(&mut self, amount: usize) { - self.row = self.row.saturating_sub(amount); - } - - pub fn move_down(&mut self, amount: usize, max: usize) { - self.row = (self.row + amount).min(max); - } - - pub fn move_left(&mut self, amount: usize) { - self.col = self.col.saturating_sub(amount); - } - - pub fn move_right(&mut self, amount: usize, max: usize) { - self.col = (self.col + amount).min(max); - } - - pub fn as_tuple(&self) -> (usize, usize) { - (self.row, self.col) - } -} diff --git a/crates/owlen-core/src/storage.rs b/crates/owlen-core/src/storage.rs deleted file mode 100644 index 5b98be0..0000000 --- a/crates/owlen-core/src/storage.rs +++ /dev/null @@ -1,558 +0,0 @@ -//! Session persistence and storage management backed by SQLite - -// TODO: Upgrade to generic-array 1.x to remove deprecation warnings -#![allow(deprecated)] - -use crate::types::Conversation; -use crate::{Error, Result}; -use aes_gcm::aead::{Aead, KeyInit}; -use aes_gcm::{Aes256Gcm, Nonce}; -use ring::rand::{SecureRandom, SystemRandom}; -use serde::{Deserialize, Serialize}; -use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; -use sqlx::{Pool, Row, Sqlite}; -use std::fs; -use std::io::IsTerminal; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::str::FromStr; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use uuid::Uuid; - -/// Metadata about a saved session -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionMeta { - /// Conversation ID - pub id: Uuid, - /// Optional session name - pub name: Option, - /// Optional AI-generated description - pub description: Option, - /// Number of messages in the conversation - pub message_count: usize, - /// Model used - pub model: String, - /// When the session was created - pub created_at: SystemTime, - /// When the session was last updated - pub updated_at: SystemTime, -} - -/// Storage manager for persisting conversations in SQLite -pub struct StorageManager { - pool: Pool, - database_path: PathBuf, -} - -impl StorageManager { - /// Create a new storage manager using the default database path - pub async fn new() -> Result { - let db_path = Self::default_database_path()?; - Self::with_database_path(db_path).await - } - - /// Create a storage manager using the provided database path - pub async fn with_database_path(database_path: PathBuf) -> Result { - if let Some(parent) = database_path.parent() && !parent.exists() { - std::fs::create_dir_all(parent).map_err(|e| { - Error::Storage(format!( - "Failed to create database directory {parent:?}: {e}" - )) - })?; - } - - let options = SqliteConnectOptions::from_str(&format!( - "sqlite://{}", - database_path - .to_str() - .ok_or_else(|| Error::Storage("Invalid database path".to_string()))? - )) - .map_err(|e| Error::Storage(format!("Invalid database URL: {e}")))? - .create_if_missing(true) - .journal_mode(SqliteJournalMode::Wal) - .synchronous(SqliteSynchronous::Normal); - - let pool = SqlitePoolOptions::new() - .max_connections(5) - .connect_with(options) - .await - .map_err(|e| Error::Storage(format!("Failed to connect to database: {e}")))?; - - sqlx::migrate!("./migrations") - .run(&pool) - .await - .map_err(|e| Error::Storage(format!("Failed to run database migrations: {e}")))?; - - let storage = Self { - pool, - database_path, - }; - - storage.try_migrate_legacy_sessions().await?; - - Ok(storage) - } - - /// Save a conversation. Existing entries are updated in-place. - pub async fn save_conversation( - &self, - conversation: &Conversation, - name: Option, - ) -> Result<()> { - self.save_conversation_with_description(conversation, name, None) - .await - } - - /// Save a conversation with an optional description override - pub async fn save_conversation_with_description( - &self, - conversation: &Conversation, - name: Option, - description: Option, - ) -> Result<()> { - let mut serialized = conversation.clone(); - if name.is_some() { - serialized.name = name.clone(); - } - if description.is_some() { - serialized.description = description.clone(); - } - - let data = serde_json::to_string(&serialized) - .map_err(|e| Error::Storage(format!("Failed to serialize conversation: {e}")))?; - - let created_at = to_epoch_seconds(serialized.created_at); - let updated_at = to_epoch_seconds(serialized.updated_at); - let message_count = serialized.messages.len() as i64; - - sqlx::query( - r#" - INSERT INTO conversations ( - id, - name, - description, - model, - message_count, - created_at, - updated_at, - data - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - description = excluded.description, - model = excluded.model, - message_count = excluded.message_count, - created_at = excluded.created_at, - updated_at = excluded.updated_at, - data = excluded.data - "#, - ) - .bind(serialized.id.to_string()) - .bind(name.or(serialized.name.clone())) - .bind(description.or(serialized.description.clone())) - .bind(&serialized.model) - .bind(message_count) - .bind(created_at) - .bind(updated_at) - .bind(data) - .execute(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to save conversation: {e}")))?; - - Ok(()) - } - - /// Load a conversation by ID - pub async fn load_conversation(&self, id: Uuid) -> Result { - let record = sqlx::query(r#"SELECT data FROM conversations WHERE id = ?1"#) - .bind(id.to_string()) - .fetch_optional(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to load conversation: {e}")))?; - - let row = - record.ok_or_else(|| Error::Storage(format!("No conversation found with id {id}")))?; - - let data: String = row - .try_get("data") - .map_err(|e| Error::Storage(format!("Failed to read conversation payload: {e}")))?; - - serde_json::from_str(&data) - .map_err(|e| Error::Storage(format!("Failed to deserialize conversation: {e}"))) - } - - /// List metadata for all saved conversations ordered by most recent update - pub async fn list_sessions(&self) -> Result> { - let rows = sqlx::query( - r#" - SELECT id, name, description, model, message_count, created_at, updated_at - FROM conversations - ORDER BY updated_at DESC - "#, - ) - .fetch_all(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to list sessions: {e}")))?; - - let mut sessions = Vec::with_capacity(rows.len()); - for row in rows { - let id_text: String = row - .try_get("id") - .map_err(|e| Error::Storage(format!("Failed to read id column: {e}")))?; - let id = Uuid::parse_str(&id_text) - .map_err(|e| Error::Storage(format!("Invalid UUID in storage: {e}")))?; - - let message_count: i64 = row - .try_get("message_count") - .map_err(|e| Error::Storage(format!("Failed to read message count: {e}")))?; - - let created_at: i64 = row - .try_get("created_at") - .map_err(|e| Error::Storage(format!("Failed to read created_at: {e}")))?; - let updated_at: i64 = row - .try_get("updated_at") - .map_err(|e| Error::Storage(format!("Failed to read updated_at: {e}")))?; - - sessions.push(SessionMeta { - id, - name: row - .try_get("name") - .map_err(|e| Error::Storage(format!("Failed to read name: {e}")))?, - description: row - .try_get("description") - .map_err(|e| Error::Storage(format!("Failed to read description: {e}")))?, - model: row - .try_get("model") - .map_err(|e| Error::Storage(format!("Failed to read model: {e}")))?, - message_count: message_count as usize, - created_at: from_epoch_seconds(created_at), - updated_at: from_epoch_seconds(updated_at), - }); - } - - Ok(sessions) - } - - /// Delete a conversation by ID - pub async fn delete_session(&self, id: Uuid) -> Result<()> { - sqlx::query("DELETE FROM conversations WHERE id = ?1") - .bind(id.to_string()) - .execute(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to delete conversation: {e}")))?; - Ok(()) - } - - pub async fn store_secure_item( - &self, - key: &str, - plaintext: &[u8], - master_key: &[u8], - ) -> Result<()> { - let cipher = create_cipher(master_key)?; - let nonce_bytes = generate_nonce()?; - let nonce = Nonce::from_slice(&nonce_bytes); - let ciphertext = cipher - .encrypt(nonce, plaintext) - .map_err(|e| Error::Storage(format!("Failed to encrypt secure item: {e}")))?; - - let now = to_epoch_seconds(SystemTime::now()); - - sqlx::query( - r#" - INSERT INTO secure_items (key, nonce, ciphertext, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(key) DO UPDATE SET - nonce = excluded.nonce, - ciphertext = excluded.ciphertext, - updated_at = excluded.updated_at - "#, - ) - .bind(key) - .bind(&nonce_bytes[..]) - .bind(&ciphertext[..]) - .bind(now) - .bind(now) - .execute(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to store secure item: {e}")))?; - - Ok(()) - } - - pub async fn load_secure_item(&self, key: &str, master_key: &[u8]) -> Result>> { - let record = sqlx::query("SELECT nonce, ciphertext FROM secure_items WHERE key = ?1") - .bind(key) - .fetch_optional(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to load secure item: {e}")))?; - - let Some(row) = record else { - return Ok(None); - }; - - let nonce_bytes: Vec = row - .try_get("nonce") - .map_err(|e| Error::Storage(format!("Failed to read secure item nonce: {e}")))?; - let ciphertext: Vec = row - .try_get("ciphertext") - .map_err(|e| Error::Storage(format!("Failed to read secure item ciphertext: {e}")))?; - - if nonce_bytes.len() != 12 { - return Err(Error::Storage( - "Invalid nonce length for secure item".to_string(), - )); - } - - let cipher = create_cipher(master_key)?; - let nonce = Nonce::from_slice(&nonce_bytes); - let plaintext = cipher - .decrypt(nonce, ciphertext.as_ref()) - .map_err(|e| Error::Storage(format!("Failed to decrypt secure item: {e}")))?; - - Ok(Some(plaintext)) - } - - pub async fn delete_secure_item(&self, key: &str) -> Result<()> { - sqlx::query("DELETE FROM secure_items WHERE key = ?1") - .bind(key) - .execute(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to delete secure item: {e}")))?; - Ok(()) - } - - pub async fn clear_secure_items(&self) -> Result<()> { - sqlx::query("DELETE FROM secure_items") - .execute(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to clear secure items: {e}")))?; - Ok(()) - } - - /// Database location used by this storage manager - pub fn database_path(&self) -> &Path { - &self.database_path - } - - /// Determine default database path (platform specific) - pub fn default_database_path() -> Result { - let data_dir = dirs::data_local_dir() - .ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?; - Ok(data_dir.join("owlen").join("owlen.db")) - } - - fn legacy_sessions_dir() -> Result { - let data_dir = dirs::data_local_dir() - .ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?; - Ok(data_dir.join("owlen").join("sessions")) - } - - async fn database_has_records(&self) -> Result { - let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations") - .fetch_one(&self.pool) - .await - .map_err(|e| Error::Storage(format!("Failed to inspect database: {e}")))?; - Ok(count > 0) - } - - async fn try_migrate_legacy_sessions(&self) -> Result<()> { - if self.database_has_records().await? { - return Ok(()); - } - - let legacy_dir = match Self::legacy_sessions_dir() { - Ok(dir) => dir, - Err(_) => return Ok(()), - }; - - if !legacy_dir.exists() { - return Ok(()); - } - - let entries = fs::read_dir(&legacy_dir).map_err(|e| { - Error::Storage(format!("Failed to read legacy sessions directory: {e}")) - })?; - - let mut json_files = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("json") { - json_files.push(path); - } - } - - if json_files.is_empty() { - return Ok(()); - } - - if !io::stdin().is_terminal() { - return Ok(()); - } - - println!( - "Legacy OWLEN session files were found in {}.", - legacy_dir.display() - ); - if !prompt_yes_no("Migrate them to the new SQLite storage? (y/N) ")? { - println!("Skipping legacy session migration."); - return Ok(()); - } - - println!("Migrating legacy sessions..."); - let mut migrated = 0usize; - for path in &json_files { - match fs::read_to_string(path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(conversation) => { - if let Err(err) = self - .save_conversation_with_description( - &conversation, - conversation.name.clone(), - conversation.description.clone(), - ) - .await - { - println!(" • Failed to migrate {}: {}", path.display(), err); - } else { - migrated += 1; - } - } - Err(err) => { - println!( - " • Failed to parse conversation {}: {}", - path.display(), - err - ); - } - }, - Err(err) => { - println!(" • Failed to read {}: {}", path.display(), err); - } - } - } - - if migrated > 0 && let Err(err) = archive_legacy_directory(&legacy_dir) { - println!( - "Warning: migrated sessions but failed to archive legacy directory: {}", - err - ); - } - - println!("Migrated {} legacy sessions.", migrated); - Ok(()) - } -} - -fn to_epoch_seconds(time: SystemTime) -> i64 { - match time.duration_since(UNIX_EPOCH) { - Ok(duration) => duration.as_secs() as i64, - Err(_) => 0, - } -} - -fn from_epoch_seconds(seconds: i64) -> SystemTime { - UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64) -} - -fn prompt_yes_no(prompt: &str) -> Result { - print!("{}", prompt); - io::stdout() - .flush() - .map_err(|e| Error::Storage(format!("Failed to flush stdout: {e}")))?; - - let mut input = String::new(); - io::stdin() - .read_line(&mut input) - .map_err(|e| Error::Storage(format!("Failed to read input: {e}")))?; - let trimmed = input.trim().to_lowercase(); - Ok(matches!(trimmed.as_str(), "y" | "yes")) -} - -fn archive_legacy_directory(legacy_dir: &Path) -> Result<()> { - let mut backup_dir = legacy_dir.with_file_name("sessions_legacy_backup"); - let mut counter = 1; - while backup_dir.exists() { - backup_dir = legacy_dir.with_file_name(format!("sessions_legacy_backup_{}", counter)); - counter += 1; - } - - fs::rename(legacy_dir, &backup_dir).map_err(|e| { - Error::Storage(format!( - "Failed to archive legacy sessions directory {}: {}", - legacy_dir.display(), - e - )) - })?; - - println!("Legacy session files archived to {}", backup_dir.display()); - Ok(()) -} - -fn create_cipher(master_key: &[u8]) -> Result { - if master_key.len() != 32 { - return Err(Error::Storage( - "Master key must be 32 bytes for AES-256-GCM".to_string(), - )); - } - Aes256Gcm::new_from_slice(master_key).map_err(|_| { - Error::Storage("Failed to initialize cipher with provided master key".to_string()) - }) -} - -fn generate_nonce() -> Result<[u8; 12]> { - let mut nonce = [0u8; 12]; - SystemRandom::new() - .fill(&mut nonce) - .map_err(|_| Error::Storage("Failed to generate nonce".to_string()))?; - Ok(nonce) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::{Conversation, Message}; - use tempfile::tempdir; - - fn sample_conversation() -> Conversation { - Conversation { - id: Uuid::new_v4(), - name: Some("Test conversation".to_string()), - description: Some("A sample conversation".to_string()), - messages: vec![ - Message::user("Hello".to_string()), - Message::assistant("Hi".to_string()), - ], - model: "test-model".to_string(), - created_at: SystemTime::now(), - updated_at: SystemTime::now(), - } - } - - #[tokio::test] - async fn test_storage_lifecycle() { - let temp_dir = tempdir().expect("failed to create temp dir"); - let db_path = temp_dir.path().join("owlen.db"); - let storage = StorageManager::with_database_path(db_path).await.unwrap(); - - let conversation = sample_conversation(); - storage - .save_conversation(&conversation, None) - .await - .expect("failed to save conversation"); - - let sessions = storage.list_sessions().await.unwrap(); - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].id, conversation.id); - - let loaded = storage.load_conversation(conversation.id).await.unwrap(); - assert_eq!(loaded.messages.len(), 2); - - storage - .delete_session(conversation.id) - .await - .expect("failed to delete conversation"); - let sessions = storage.list_sessions().await.unwrap(); - assert!(sessions.is_empty()); - } -} diff --git a/crates/owlen-core/src/tools.rs b/crates/owlen-core/src/tools.rs deleted file mode 100644 index 46b2fb4..0000000 --- a/crates/owlen-core/src/tools.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Tool module aggregating built‑in tool implementations. -//! -//! The crate originally declared `pub mod tools;` in `lib.rs` but the source -//! directory only contained individual tool files without a `mod.rs`, causing the -//! compiler to look for `tools.rs` and fail. Adding this module file makes the -//! directory a proper Rust module and re‑exports the concrete tool types. - -pub mod code_exec; -pub mod fs_tools; -pub mod registry; -pub mod web_scrape; -pub mod web_search; - -use async_trait::async_trait; -use once_cell::sync::Lazy; -use regex::Regex; -use serde_json::{Value, json}; -use std::collections::HashMap; -use std::time::Duration; - -use crate::Result; - -/// MCP mandates tool identifiers to match `^[A-Za-z0-9_-]{1,64}$`. -pub const MAX_TOOL_IDENTIFIER_LEN: usize = 64; - -static TOOL_IDENTIFIER_RE: Lazy = - Lazy::new(|| Regex::new(r"^[A-Za-z0-9_-]{1,64}$").expect("valid tool identifier regex")); - -pub const WEB_SEARCH_TOOL_NAME: &str = "web_search"; - -/// Return the canonical identifier for a tool. -pub fn canonical_tool_name(name: &str) -> &str { - name -} - -/// Check whether two tool identifiers refer to the same logical tool. -pub fn tool_name_matches(lhs: &str, rhs: &str) -> bool { - canonical_tool_name(lhs) == canonical_tool_name(rhs) -} - -/// Determine whether the provided identifier satisfies the MCP naming contract. -pub fn is_valid_tool_identifier(name: &str) -> bool { - TOOL_IDENTIFIER_RE.is_match(name) -} - -/// Provide lint-style feedback when a tool identifier falls outside the MCP rules. -pub fn tool_identifier_violation(name: &str) -> Option { - if name.is_empty() { - return Some("Tool identifiers must not be empty.".to_string()); - } - - if name.len() > MAX_TOOL_IDENTIFIER_LEN { - return Some(format!( - "Tool identifier '{name}' exceeds the {MAX_TOOL_IDENTIFIER_LEN}-character MCP limit." - )); - } - - if name.trim() != name { - return Some(format!( - "Tool identifier '{name}' contains leading or trailing whitespace." - )); - } - - if !TOOL_IDENTIFIER_RE.is_match(name) { - return Some(format!( - "Tool identifier '{name}' may only contain ASCII letters, digits, hyphens, or underscores." - )); - } - - None -} - -/// Trait representing a tool that can be called via the MCP interface. -#[async_trait] -pub trait Tool: Send + Sync { - /// Unique name of the tool (used in the MCP protocol). - fn name(&self) -> &'static str; - /// Human‑readable description for documentation. - fn description(&self) -> &'static str; - /// JSON‑Schema describing the expected arguments. - fn schema(&self) -> Value; - /// Execute the tool with the provided arguments. - fn requires_network(&self) -> bool { - false - } - fn requires_filesystem(&self) -> Vec { - Vec::new() - } - /// Optional additional identifiers (must remain spec-compliant). - fn aliases(&self) -> &'static [&'static str] { - &[] - } - async fn execute(&self, args: Value) -> Result; -} - -/// Result returned by a tool execution. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ToolResult { - /// Indicates whether the tool completed successfully. - pub success: bool, - /// Human‑readable status string – retained for compatibility. - pub status: String, - /// Arbitrary JSON payload describing the tool output. - pub output: Value, - /// Execution duration. - #[serde(skip_serializing_if = "Duration::is_zero", default)] - pub duration: Duration, - /// Optional key/value metadata for the tool invocation. - #[serde(default)] - pub metadata: HashMap, -} - -impl ToolResult { - pub fn success(output: Value) -> Self { - Self { - success: true, - status: "success".into(), - output, - duration: Duration::default(), - metadata: HashMap::new(), - } - } - - pub fn error(msg: &str) -> Self { - Self { - success: false, - status: "error".into(), - output: json!({ "error": msg }), - duration: Duration::default(), - metadata: HashMap::new(), - } - } - - pub fn cancelled(msg: &str) -> Self { - Self { - success: false, - status: "cancelled".into(), - output: json!({ "error": msg }), - duration: Duration::default(), - metadata: HashMap::new(), - } - } -} - -// Re‑export the most commonly used types so they can be accessed as -// `owlen_core::tools::CodeExecTool`, etc. -pub use code_exec::CodeExecTool; -pub use fs_tools::{ResourcesDeleteTool, ResourcesGetTool, ResourcesListTool, ResourcesWriteTool}; -pub use registry::ToolRegistry; -pub use web_scrape::WebScrapeTool; -pub use web_search::{WebSearchSettings, WebSearchTool}; diff --git a/crates/owlen-core/src/tools/code_exec.rs b/crates/owlen-core/src/tools/code_exec.rs deleted file mode 100644 index 33ebeca..0000000 --- a/crates/owlen-core/src/tools/code_exec.rs +++ /dev/null @@ -1,148 +0,0 @@ -use std::sync::Arc; -use std::time::Instant; - -use crate::Result; -use anyhow::{Context, anyhow}; -use async_trait::async_trait; -use serde_json::{Value, json}; - -use super::{Tool, ToolResult}; -use crate::sandbox::{SandboxConfig, SandboxedProcess}; - -pub struct CodeExecTool { - allowed_languages: Arc>, -} - -impl CodeExecTool { - pub fn new(allowed_languages: Vec) -> Self { - Self { - allowed_languages: Arc::new(allowed_languages), - } - } -} - -#[async_trait] -impl Tool for CodeExecTool { - fn name(&self) -> &'static str { - "code_exec" - } - - fn description(&self) -> &'static str { - "Execute code snippets within a sandboxed environment" - } - - fn schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "language": { - "type": "string", - "enum": self.allowed_languages.as_slice(), - "description": "Language of the code block" - }, - "code": { - "type": "string", - "minLength": 1, - "maxLength": 10000, - "description": "Code to execute" - }, - "timeout": { - "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 30, - "description": "Execution timeout in seconds" - } - }, - "required": ["language", "code"], - "additionalProperties": false - }) - } - - async fn execute(&self, args: Value) -> Result { - let start = Instant::now(); - - let language = args - .get("language") - .and_then(Value::as_str) - .context("Missing language parameter")?; - let code = args - .get("code") - .and_then(Value::as_str) - .context("Missing code parameter")?; - let timeout = args.get("timeout").and_then(Value::as_u64).unwrap_or(30); - - if !self.allowed_languages.iter().any(|lang| lang == language) { - return Err(anyhow!("Language '{}' not permitted", language).into()); - } - - let (command, command_args) = match language { - "python" => ( - "python3".to_string(), - vec!["-c".to_string(), code.to_string()], - ), - "javascript" => ("node".to_string(), vec!["-e".to_string(), code.to_string()]), - "bash" => ("bash".to_string(), vec!["-c".to_string(), code.to_string()]), - "rust" => { - let mut result = - ToolResult::error("Rust execution is not yet supported in the sandbox"); - result.duration = start.elapsed(); - return Ok(result); - } - other => return Err(anyhow!("Unsupported language: {}", other).into()), - }; - - let sandbox_config = SandboxConfig { - allow_network: false, - timeout_seconds: timeout, - ..Default::default() - }; - - let sandbox_result = tokio::task::spawn_blocking(move || { - let sandbox = SandboxedProcess::new(sandbox_config)?; - let arg_refs: Vec<&str> = command_args.iter().map(|s| s.as_str()).collect(); - sandbox.execute(&command, &arg_refs) - }) - .await - .context("Sandbox execution task failed")??; - - let mut result = if sandbox_result.exit_code == 0 { - ToolResult::success(json!({ - "stdout": sandbox_result.stdout, - "stderr": sandbox_result.stderr, - "exit_code": sandbox_result.exit_code, - "timed_out": sandbox_result.was_timeout, - })) - } else { - let error_msg = if sandbox_result.was_timeout { - format!( - "Execution timed out after {} seconds (exit code {}): {}", - timeout, sandbox_result.exit_code, sandbox_result.stderr - ) - } else { - format!( - "Execution failed with status {}: {}", - sandbox_result.exit_code, sandbox_result.stderr - ) - }; - let mut err_result = ToolResult::error(&error_msg); - err_result.output = json!({ - "stdout": sandbox_result.stdout, - "stderr": sandbox_result.stderr, - "exit_code": sandbox_result.exit_code, - "timed_out": sandbox_result.was_timeout, - }); - err_result - }; - - result.duration = start.elapsed(); - result - .metadata - .insert("language".to_string(), language.to_string()); - result - .metadata - .insert("timeout_seconds".to_string(), timeout.to_string()); - - Ok(result) - } -} diff --git a/crates/owlen-core/src/tools/fs_tools.rs b/crates/owlen-core/src/tools/fs_tools.rs deleted file mode 100644 index d0c66a1..0000000 --- a/crates/owlen-core/src/tools/fs_tools.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::tools::{Tool, ToolResult}; -use crate::{Error, Result}; -use async_trait::async_trait; -use path_clean::PathClean; -use serde::Deserialize; -use serde_json::json; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; - -#[derive(Deserialize)] -struct FileArgs { - path: String, -} - -fn sanitize_path(path: &str, root: &Path) -> Result { - let path = Path::new(path); - let path = if path.is_absolute() { - // Strip leading '/' to treat as relative to the project root. - path.strip_prefix("/") - .map_err(|_| Error::InvalidInput("Invalid path".into()))? - .to_path_buf() - } else { - path.to_path_buf() - }; - - let full_path = root.join(path).clean(); - - if !full_path.starts_with(root) { - return Err(Error::PermissionDenied("Path traversal detected".into())); - } - - Ok(full_path) -} - -pub struct ResourcesListTool; - -#[async_trait] -impl Tool for ResourcesListTool { - fn name(&self) -> &'static str { - "resources_list" - } - - fn description(&self) -> &'static str { - "Lists directory contents." - } - - fn schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The path to the directory to list." - } - }, - "required": ["path"] - }) - } - - async fn execute(&self, args: serde_json::Value) -> Result { - let args: FileArgs = serde_json::from_value(args)?; - let root = env::current_dir()?; - let full_path = sanitize_path(&args.path, &root)?; - - let entries = fs::read_dir(full_path)?; - - let mut result = Vec::new(); - for entry in entries { - let entry = entry?; - result.push(entry.file_name().to_string_lossy().to_string()); - } - - Ok(ToolResult::success(serde_json::to_value(result)?)) - } -} - -pub struct ResourcesGetTool; - -#[async_trait] -impl Tool for ResourcesGetTool { - fn name(&self) -> &'static str { - "resources_get" - } - - fn description(&self) -> &'static str { - "Reads file content." - } - - fn schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The path to the file to read." - } - }, - "required": ["path"] - }) - } - - async fn execute(&self, args: serde_json::Value) -> Result { - let args: FileArgs = serde_json::from_value(args)?; - let root = env::current_dir()?; - let full_path = sanitize_path(&args.path, &root)?; - - let content = fs::read_to_string(full_path)?; - - Ok(ToolResult::success(serde_json::to_value(content)?)) - } -} - -// --------------------------------------------------------------------------- -// Write tool – writes (or overwrites) a file under the project root. -// --------------------------------------------------------------------------- -pub struct ResourcesWriteTool; - -#[derive(Deserialize)] -struct WriteArgs { - path: String, - content: String, -} - -#[async_trait] -impl Tool for ResourcesWriteTool { - fn name(&self) -> &'static str { - "resources_write" - } - fn description(&self) -> &'static str { - "Writes (or overwrites) a file. Requires explicit consent." - } - fn schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Target file path (relative to project root)" }, - "content": { "type": "string", "description": "File content to write" } - }, - "required": ["path", "content"] - }) - } - fn requires_filesystem(&self) -> Vec { - vec!["file_write".to_string()] - } - async fn execute(&self, args: serde_json::Value) -> Result { - let args: WriteArgs = serde_json::from_value(args)?; - let root = env::current_dir()?; - let full_path = sanitize_path(&args.path, &root)?; - // Ensure the parent directory exists - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(full_path, args.content)?; - Ok(ToolResult::success(json!(null))) - } -} - -// --------------------------------------------------------------------------- -// Delete tool – deletes a file under the project root. -// --------------------------------------------------------------------------- -pub struct ResourcesDeleteTool; - -#[derive(Deserialize)] -struct DeleteArgs { - path: String, -} - -#[async_trait] -impl Tool for ResourcesDeleteTool { - fn name(&self) -> &'static str { - "resources_delete" - } - fn description(&self) -> &'static str { - "Deletes a file. Requires explicit consent." - } - fn schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { "path": { "type": "string", "description": "File path to delete" } }, - "required": ["path"] - }) - } - fn requires_filesystem(&self) -> Vec { - vec!["file_delete".to_string()] - } - async fn execute(&self, args: serde_json::Value) -> Result { - let args: DeleteArgs = serde_json::from_value(args)?; - let root = env::current_dir()?; - let full_path = sanitize_path(&args.path, &root)?; - if full_path.is_file() { - fs::remove_file(full_path)?; - Ok(ToolResult::success(json!(null))) - } else { - Err(Error::InvalidInput("Path does not refer to a file".into())) - } - } -} diff --git a/crates/owlen-core/src/tools/registry.rs b/crates/owlen-core/src/tools/registry.rs deleted file mode 100644 index 3aa8890..0000000 --- a/crates/owlen-core/src/tools/registry.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use crate::{Error, Result}; -use anyhow::Context; -use serde_json::Value; - -use super::{ - Tool, ToolResult, WEB_SEARCH_TOOL_NAME, canonical_tool_name, tool_identifier_violation, -}; -use crate::config::Config; -use crate::mode::Mode; -use crate::ui::UiController; - -pub struct ToolRegistry { - tools: HashMap>, - config: Arc>, - ui: Arc, -} - -impl ToolRegistry { - pub fn new(config: Arc>, ui: Arc) -> Self { - Self { - tools: HashMap::new(), - config, - ui, - } - } - - pub fn register(&mut self, tool: T) -> Result<()> - where - T: Tool + 'static, - { - let tool: Arc = Arc::new(tool); - let name = tool.name(); - - if let Some(reason) = tool_identifier_violation(name) { - log::error!("Tool '{}' failed validation: {}", name, reason); - return Err(Error::InvalidInput(format!( - "Tool '{name}' is not a valid MCP identifier: {reason}" - ))); - } - - if self - .tools - .insert(name.to_string(), Arc::clone(&tool)) - .is_some() - { - log::warn!( - "Tool '{}' was already registered; overwriting previous entry.", - name - ); - } - - Ok(()) - } - - pub fn get(&self, name: &str) -> Option> { - self.tools.get(name).cloned() - } - - pub fn all(&self) -> Vec> { - self.tools.values().cloned().collect() - } - - pub async fn execute(&self, name: &str, args: Value, mode: Mode) -> Result { - let canonical = canonical_tool_name(name); - let tool = self - .get(canonical) - .with_context(|| format!("Tool not registered: {}", name))?; - - let mut config = self.config.lock().await; - - // Check mode-based tool availability first - if !(config.modes.is_tool_allowed(mode, canonical) - || config.modes.is_tool_allowed(mode, name)) - { - let alternate_mode = match mode { - Mode::Chat => Mode::Code, - Mode::Code => Mode::Chat, - }; - - if config.modes.is_tool_allowed(alternate_mode, canonical) - || config.modes.is_tool_allowed(alternate_mode, name) - { - return Ok(ToolResult::error(&format!( - "Tool '{}' is not available in {} mode. Switch to {} mode to use this tool (use :mode {} command).", - name, mode, alternate_mode, alternate_mode - ))); - } else { - return Ok(ToolResult::error(&format!( - "Tool '{}' is not available in any mode. Check your configuration.", - name - ))); - } - } - - let is_enabled = match canonical { - WEB_SEARCH_TOOL_NAME => config.tools.web_search.enabled, - "code_exec" => config.tools.code_exec.enabled, - _ => true, // All other tools are considered enabled by default - }; - - if !is_enabled { - let prompt = format!( - "Tool '{}' is disabled. Would you like to enable it for this session?", - name - ); - if self.ui.confirm(&prompt).await { - // Enable the tool in the in-memory config for the current session - match canonical { - WEB_SEARCH_TOOL_NAME => config.tools.web_search.enabled = true, - "code_exec" => config.tools.code_exec.enabled = true, - _ => {} - } - } else { - return Ok(ToolResult::cancelled(&format!( - "Tool '{}' execution was cancelled by the user.", - name - ))); - } - } - - tool.execute(args).await - } - - /// Get all tools available in the given mode - pub async fn available_tools(&self, mode: Mode) -> Vec { - let config = self.config.lock().await; - self.tools - .keys() - .filter(|name| config.modes.is_tool_allowed(mode, name)) - .cloned() - .collect() - } - - pub fn tools(&self) -> Vec { - self.tools.keys().cloned().collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::tools::{Tool, ToolResult, WEB_SEARCH_TOOL_NAME}; - use crate::ui::NoOpUiController; - use async_trait::async_trait; - use serde_json::{Value, json}; - use std::sync::Arc; - - struct DummyTool { - name: &'static str, - } - - #[async_trait] - impl Tool for DummyTool { - fn name(&self) -> &'static str { - self.name - } - - fn description(&self) -> &'static str { - "dummy tool" - } - - fn schema(&self) -> Value { - json!({ "type": "object" }) - } - - fn aliases(&self) -> &'static [&'static str] { - &[] - } - - async fn execute(&self, _args: Value) -> Result { - Ok(ToolResult::success(json!({ "echo": true }))) - } - } - - fn registry() -> ToolRegistry { - let config = Arc::new(tokio::sync::Mutex::new(Config::default())); - let ui = Arc::new(NoOpUiController); - ToolRegistry::new(config, ui) - } - - #[test] - fn rejects_invalid_tool_identifier() { - let mut registry = registry(); - let tool = DummyTool { - name: "invalid.tool", - }; - - let err = registry.register(tool).unwrap_err(); - assert!(matches!(err, Error::InvalidInput(_))); - } - - #[test] - fn registers_spec_compliant_tool() { - let mut registry = registry(); - let tool = DummyTool { - name: WEB_SEARCH_TOOL_NAME, - }; - - registry.register(tool).unwrap(); - assert!(registry.get(WEB_SEARCH_TOOL_NAME).is_some()); - } -} diff --git a/crates/owlen-core/src/tools/web_scrape.rs b/crates/owlen-core/src/tools/web_scrape.rs deleted file mode 100644 index d281a8f..0000000 --- a/crates/owlen-core/src/tools/web_scrape.rs +++ /dev/null @@ -1,102 +0,0 @@ -use super::{Tool, ToolResult}; -use crate::Result; -use anyhow::Context; -use async_trait::async_trait; -use reqwest::Client; -use serde_json::{Value, json}; - -/// Tool that fetches the raw HTML content for a list of URLs. -/// -/// Input schema expects: -/// urls: array of strings (max 5 URLs) -/// timeout_secs: optional integer per‑request timeout (default 10) -pub struct WebScrapeTool { - client: Client, -} - -impl Default for WebScrapeTool { - fn default() -> Self { - Self::new() - } -} - -impl WebScrapeTool { - pub fn new() -> Self { - let client = Client::builder() - .user_agent("OwlenWebScrape/0.1") - .build() - .expect("Failed to build reqwest client"); - Self { client } - } -} - -#[async_trait] -impl Tool for WebScrapeTool { - fn name(&self) -> &'static str { - "web_scrape" - } - - fn description(&self) -> &'static str { - "Fetch raw HTML content for a list of URLs" - } - - fn schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "urls": { - "type": "array", - "items": { "type": "string", "format": "uri" }, - "minItems": 1, - "maxItems": 5, - "description": "List of URLs to scrape" - }, - "timeout_secs": { - "type": "integer", - "minimum": 1, - "maximum": 30, - "default": 10, - "description": "Per‑request timeout in seconds" - } - }, - "required": ["urls"], - "additionalProperties": false - }) - } - - fn requires_network(&self) -> bool { - true - } - - async fn execute(&self, args: Value) -> Result { - let urls = args - .get("urls") - .and_then(|v| v.as_array()) - .context("Missing 'urls' array")?; - let timeout_secs = args - .get("timeout_secs") - .and_then(|v| v.as_u64()) - .unwrap_or(10); - - let mut results = Vec::new(); - for url_val in urls { - let url = url_val.as_str().unwrap_or(""); - let resp = self - .client - .get(url) - .timeout(std::time::Duration::from_secs(timeout_secs)) - .send() - .await; - match resp { - Ok(r) => { - let text = r.text().await.unwrap_or_default(); - results.push(json!({ "url": url, "content": text })); - } - Err(e) => { - results.push(json!({ "url": url, "error": e.to_string() })); - } - } - } - Ok(ToolResult::success(json!({ "pages": results }))) - } -} diff --git a/crates/owlen-core/src/tools/web_search.rs b/crates/owlen-core/src/tools/web_search.rs deleted file mode 100644 index 8c3d8e3..0000000 --- a/crates/owlen-core/src/tools/web_search.rs +++ /dev/null @@ -1,165 +0,0 @@ -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use crate::Result; -use anyhow::{Context, anyhow}; -use async_trait::async_trait; -use reqwest::{Client, StatusCode, Url}; -use serde_json::{Value, json}; - -use super::{Tool, ToolResult}; -use crate::consent::ConsentManager; -use crate::tools::WEB_SEARCH_TOOL_NAME; - -/// Configuration applied to the web search tool at registration time. -#[derive(Clone, Debug)] -pub struct WebSearchSettings { - pub endpoint: Url, - pub api_key: String, - pub provider_label: String, - pub timeout: Duration, -} - -pub struct WebSearchTool { - consent_manager: Arc>, - client: Client, - settings: WebSearchSettings, -} - -impl WebSearchTool { - pub fn new(consent_manager: Arc>, settings: WebSearchSettings) -> Self { - let client = Client::builder() - .timeout(settings.timeout) - .build() - .expect("failed to construct reqwest client for web search"); - - Self { - consent_manager, - client, - settings, - } - } -} - -#[async_trait] -impl Tool for WebSearchTool { - fn name(&self) -> &'static str { - WEB_SEARCH_TOOL_NAME - } - - fn description(&self) -> &'static str { - "Search the web using the active cloud provider." - } - - fn schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Search query text" - }, - "max_results": { - "type": "integer", - "minimum": 1, - "maximum": 10, - "default": 5, - "description": "Maximum number of search results to retrieve" - } - }, - "required": ["query"], - "additionalProperties": false - }) - } - - fn requires_network(&self) -> bool { - true - } - - async fn execute(&self, args: Value) -> Result { - let start = Instant::now(); - - { - let consent = self - .consent_manager - .lock() - .expect("Consent manager mutex poisoned"); - - if !consent.has_consent(self.name()) { - return Ok(ToolResult::error( - "Consent not granted for web search. Enable the tool from the UI before invoking it.", - )); - } - } - - let query = args - .get("query") - .and_then(Value::as_str) - .map(str::trim) - .filter(|q| !q.is_empty()) - .ok_or_else(|| anyhow!("Missing query parameter"))?; - - let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as u32; - - let payload = json!({ - "query": query, - "max_results": max_results - }); - - let response = self - .client - .post(self.settings.endpoint.clone()) - .bearer_auth(&self.settings.api_key) - .json(&payload) - .send() - .await - .context("Web search request failed")?; - - match response.status() { - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { - return Ok(ToolResult::error( - "Cloud web search request was not authorized. Verify your Ollama Cloud API key.", - )); - } - StatusCode::TOO_MANY_REQUESTS => { - return Ok(ToolResult::error( - "Cloud web search is rate limited. Please wait before retrying.", - )); - } - status if !status.is_success() => { - return Ok(ToolResult::error(&format!( - "Cloud web search failed with status {}", - status - ))); - } - _ => {} - } - - let body: Value = response - .json() - .await - .context("Failed to decode cloud search response")?; - - let results = body - .get("results") - .and_then(|value| value.as_array()) - .cloned() - .unwrap_or_else(Vec::new); - - let mut metadata = HashMap::new(); - metadata.insert("provider".to_string(), self.settings.provider_label.clone()); - - let mut result = ToolResult::success(json!({ - "query": query, - "provider": self.settings.provider_label, - "results": results, - })); - result.duration = start.elapsed(); - result.metadata = metadata; - - Ok(result) - } -} diff --git a/crates/owlen-core/src/types.rs b/crates/owlen-core/src/types.rs deleted file mode 100644 index 752a7ad..0000000 --- a/crates/owlen-core/src/types.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! Core types used across OWLEN - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fmt; -use std::path::PathBuf; -use uuid::Uuid; - -/// A message in a conversation -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct Message { - /// Unique identifier for this message - pub id: Uuid, - /// Role of the message sender (user, assistant, system) - pub role: Role, - /// Content of the message - pub content: String, - /// Optional metadata - pub metadata: HashMap, - /// Timestamp when the message was created - pub timestamp: std::time::SystemTime, - /// Tool calls requested by the assistant - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, - /// Rich attachments (images, artifacts, files) associated with the message - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attachments: Vec, -} - -/// Role of a message sender -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum Role { - /// Message from the user - User, - /// Message from the AI assistant - Assistant, - /// System message (prompts, context, etc.) - System, - /// Tool response message - Tool, -} - -/// A tool call requested by the assistant -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ToolCall { - /// Unique identifier for this tool call - pub id: String, - /// Name of the tool to call - pub name: String, - /// Arguments for the tool (JSON object) - pub arguments: serde_json::Value, -} - -fn default_mime_type() -> String { - "application/octet-stream".to_string() -} - -/// Attachment associated with a message (image, artifact, or rich output). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct MessageAttachment { - /// Unique identifier for this attachment instance. - pub id: Uuid, - /// Human friendly name, typically a filename. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Optional descriptive text supplied by the sender. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - /// MIME type describing the payload. - #[serde(default = "default_mime_type")] - pub mime_type: String, - /// Source filesystem path if the attachment originated from disk. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_path: Option, - /// Binary payload encoded as base64, when applicable. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data_base64: Option, - /// Inline UTF-8 payload when the attachment is textual. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub text_content: Option, - /// Approximate size in bytes for UI hints. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub size_bytes: Option, - /// Optional pre-rendered preview lines for fast UI rendering. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preview_lines: Option>, -} - -impl MessageAttachment { - /// Build an attachment from base64 encoded binary data. - pub fn from_base64( - name: impl Into, - mime_type: impl Into, - data_base64: String, - size_bytes: Option, - ) -> Self { - Self { - id: Uuid::new_v4(), - name: Some(name.into()), - description: None, - mime_type: mime_type.into(), - source_path: None, - data_base64: Some(data_base64), - text_content: None, - size_bytes, - preview_lines: None, - } - } - - /// Build an attachment from UTF-8 text content. - pub fn from_text(name: Option, mime_type: impl Into, text: String) -> Self { - Self { - id: Uuid::new_v4(), - name, - description: None, - mime_type: mime_type.into(), - source_path: None, - data_base64: None, - text_content: Some(text), - size_bytes: None, - preview_lines: None, - } - } - - /// Attach a source path reference to the attachment. - pub fn with_source_path(mut self, path: PathBuf) -> Self { - self.source_path = Some(path); - self - } - - /// Set the description metadata for the attachment. - pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - /// Provide pre-rendered preview lines for rapid UI display. - pub fn with_preview_lines(mut self, lines: Vec) -> Self { - if lines.is_empty() { - self.preview_lines = None; - } else { - self.preview_lines = Some(lines); - } - self - } - - /// Returns true if the attachment MIME type indicates an image. - pub fn is_image(&self) -> bool { - self.mime_type.to_ascii_lowercase().starts_with("image/") - } - - /// Accessor for base64 data payloads. - pub fn base64_data(&self) -> Option<&str> { - self.data_base64.as_deref() - } - - /// Accessor for inline text payloads. - pub fn text_data(&self) -> Option<&str> { - self.text_content.as_deref() - } -} - -impl fmt::Display for Role { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let label = match self { - Role::User => "user", - Role::Assistant => "assistant", - Role::System => "system", - Role::Tool => "tool", - }; - f.write_str(label) - } -} - -/// A conversation containing multiple messages -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Conversation { - /// Unique identifier for this conversation - pub id: Uuid, - /// Optional name/title for the conversation - pub name: Option, - /// Optional AI-generated description of the conversation - #[serde(default)] - pub description: Option, - /// Messages in chronological order - pub messages: Vec, - /// Model used for this conversation - pub model: String, - /// When the conversation was created - pub created_at: std::time::SystemTime, - /// When the conversation was last updated - pub updated_at: std::time::SystemTime, -} - -/// Configuration for a chat completion request -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatRequest { - /// The model to use for completion - pub model: String, - /// The conversation messages - pub messages: Vec, - /// Optional parameters for the request - pub parameters: ChatParameters, - /// Optional tools available for the model to use - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, -} - -/// Parameters for chat completion -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ChatParameters { - /// Temperature for randomness (0.0 to 2.0) - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - /// Maximum tokens to generate - #[serde(skip_serializing_if = "Option::is_none")] - pub max_tokens: Option, - /// Whether to stream the response - #[serde(default)] - pub stream: bool, - /// Additional provider-specific parameters - #[serde(flatten)] - #[serde(default)] - pub extra: HashMap, -} - -/// Response from a chat completion request -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatResponse { - /// The generated message - pub message: Message, - /// Token usage information - pub usage: Option, - /// Whether this is a streaming chunk - #[serde(default)] - pub is_streaming: bool, - /// Whether this is the final chunk in a stream - #[serde(default)] - pub is_final: bool, -} - -/// Token usage information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TokenUsage { - /// Tokens in the prompt - pub prompt_tokens: u32, - /// Tokens in the completion - pub completion_tokens: u32, - /// Total tokens used - pub total_tokens: u32, -} - -/// Information about an available model -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelInfo { - /// Model identifier - pub id: String, - /// Human-readable name - pub name: String, - /// Model description - pub description: Option, - /// Provider that hosts this model - pub provider: String, - /// Context window size - pub context_window: Option, - /// Additional capabilities - pub capabilities: Vec, - /// Whether this model supports tool/function calling - #[serde(default)] - pub supports_tools: bool, -} - -impl Message { - /// Create a new message - pub fn new(role: Role, content: String) -> Self { - Self { - id: Uuid::new_v4(), - role, - content, - metadata: HashMap::new(), - timestamp: std::time::SystemTime::now(), - tool_calls: None, - attachments: Vec::new(), - } - } - - /// Create a user message - pub fn user(content: String) -> Self { - Self::new(Role::User, content) - } - - /// Create an assistant message - pub fn assistant(content: String) -> Self { - Self::new(Role::Assistant, content) - } - - /// Create a system message - pub fn system(content: String) -> Self { - Self::new(Role::System, content) - } - - /// Create a tool response message - pub fn tool(tool_call_id: String, content: String) -> Self { - let mut msg = Self::new(Role::Tool, content); - msg.metadata.insert( - "tool_call_id".to_string(), - serde_json::Value::String(tool_call_id), - ); - msg - } - - /// Check if this message has tool calls - pub fn has_tool_calls(&self) -> bool { - self.tool_calls - .as_ref() - .map(|tc| !tc.is_empty()) - .unwrap_or(false) - } - - /// Attach rich artifacts to the message. - pub fn with_attachments(mut self, attachments: Vec) -> Self { - self.attachments = attachments; - self - } - - /// Return true when the message carries any attachments. - pub fn has_attachments(&self) -> bool { - !self.attachments.is_empty() - } -} - -impl Conversation { - /// Create a new conversation - pub fn new(model: String) -> Self { - let now = std::time::SystemTime::now(); - Self { - id: Uuid::new_v4(), - name: None, - description: None, - messages: Vec::new(), - model, - created_at: now, - updated_at: now, - } - } - - /// Add a message to the conversation - pub fn add_message(&mut self, message: Message) { - self.messages.push(message); - self.updated_at = std::time::SystemTime::now(); - } - - /// Get the last message in the conversation - pub fn last_message(&self) -> Option<&Message> { - self.messages.last() - } - - /// Clear all messages - pub fn clear(&mut self) { - self.messages.clear(); - self.updated_at = std::time::SystemTime::now(); - } -} diff --git a/crates/owlen-core/src/ui.rs b/crates/owlen-core/src/ui.rs deleted file mode 100644 index 37330d8..0000000 --- a/crates/owlen-core/src/ui.rs +++ /dev/null @@ -1,280 +0,0 @@ -//! Shared UI components and state management for TUI applications -//! -//! This module contains reusable UI components that can be shared between -//! different TUI applications (chat, code, etc.) - -/// Application state -pub use crate::state::AppState; - -/// Input modes for TUI applications -pub use crate::state::InputMode; - -/// Represents which panel is currently focused -pub use crate::state::FocusedPanel; - -/// Auto-scroll state manager for scrollable panels -pub use crate::state::AutoScroll; - -/// Visual selection state for text selection -pub use crate::state::VisualSelection; - -use serde::{Deserialize, Serialize}; - -/// How role labels should be rendered alongside chat messages. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum RoleLabelDisplay { - Inline, - Above, - None, -} - -/// Extract text from a selection range in a list of lines -pub fn extract_text_from_selection( - lines: &[String], - start: (usize, usize), - end: (usize, usize), -) -> Option { - if lines.is_empty() || start.0 >= lines.len() { - return None; - } - - let start_row = start.0; - let start_col = start.1; - let end_row = end.0.min(lines.len() - 1); - let end_col = end.1; - - if start_row == end_row { - // Single line selection - let line = &lines[start_row]; - let chars: Vec = line.chars().collect(); - let start_c = start_col.min(chars.len()); - let end_c = end_col.min(chars.len()); - - if start_c >= end_c { - return None; - } - - let selected: String = chars[start_c..end_c].iter().collect(); - Some(selected) - } else { - // Multi-line selection - let mut result = Vec::new(); - - // First line: from start_col to end - let first_line = &lines[start_row]; - let first_chars: Vec = first_line.chars().collect(); - let start_c = start_col.min(first_chars.len()); - if start_c < first_chars.len() { - result.push(first_chars[start_c..].iter().collect::()); - } - - // Middle lines: entire lines - for row in (start_row + 1)..end_row { - if row < lines.len() { - result.push(lines[row].clone()); - } - } - - // Last line: from start to end_col - if end_row < lines.len() && end_row > start_row { - let last_line = &lines[end_row]; - let last_chars: Vec = last_line.chars().collect(); - let end_c = end_col.min(last_chars.len()); - if end_c > 0 { - result.push(last_chars[..end_c].iter().collect::()); - } - } - - if result.is_empty() { - None - } else { - Some(result.join("\n")) - } - } -} - -/// Cursor position for navigating scrollable content -pub use crate::state::CursorPosition; - -/// Word boundary detection for navigation -pub fn find_next_word_boundary(line: &str, col: usize) -> Option { - let chars: Vec = line.chars().collect(); - - if col >= chars.len() { - return Some(chars.len()); - } - - let mut pos = col; - let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; - - // Skip current word - if is_word_char(chars[pos]) { - while pos < chars.len() && is_word_char(chars[pos]) { - pos += 1; - } - } else { - // Skip non-word characters - while pos < chars.len() && !is_word_char(chars[pos]) { - pos += 1; - } - } - - Some(pos) -} - -pub fn find_word_end(line: &str, col: usize) -> Option { - let chars: Vec = line.chars().collect(); - - if col >= chars.len() { - return Some(chars.len()); - } - - let mut pos = col; - let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; - - // If on a word character, move to end of current word - if is_word_char(chars[pos]) { - while pos < chars.len() && is_word_char(chars[pos]) { - pos += 1; - } - // Move back one to be ON the last character - pos = pos.saturating_sub(1); - } else { - // Skip non-word characters - while pos < chars.len() && !is_word_char(chars[pos]) { - pos += 1; - } - // Now on first char of next word, move to its end - while pos < chars.len() && is_word_char(chars[pos]) { - pos += 1; - } - pos = pos.saturating_sub(1); - } - - Some(pos) -} - -pub fn find_prev_word_boundary(line: &str, col: usize) -> Option { - let chars: Vec = line.chars().collect(); - - if col == 0 || chars.is_empty() { - return Some(0); - } - - let mut pos = col.min(chars.len()); - let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; - - // Move back one position first - pos = pos.saturating_sub(1); - - // Skip non-word characters - while pos > 0 && !is_word_char(chars[pos]) { - pos -= 1; - } - - // Skip word characters to find start of word - while pos > 0 && is_word_char(chars[pos - 1]) { - pos -= 1; - } - - Some(pos) -} - -use async_trait::async_trait; -use owlen_ui_common::Theme; - -pub fn apply_theme_to_string(s: &str, _theme: &Theme) -> String { - // This is a placeholder. In a real implementation, you'd parse the string - // and apply colors based on syntax or other rules. - s.to_string() -} - -/// A trait for abstracting UI interactions like confirmations. -#[async_trait] -pub trait UiController: Send + Sync { - async fn confirm(&self, prompt: &str) -> bool; -} - -/// A no-op UI controller for non-interactive contexts. -pub struct NoOpUiController; - -#[async_trait] -impl UiController for NoOpUiController { - async fn confirm(&self, _prompt: &str) -> bool { - false // Always decline in non-interactive mode - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_auto_scroll() { - let mut scroll = AutoScroll { - content_len: 100, - ..Default::default() - }; - - // Test on_viewport with stick_to_bottom - scroll.on_viewport(10); - assert_eq!(scroll.scroll, 90); - - // Test user scroll up - scroll.on_user_scroll(-10, 10); - assert_eq!(scroll.scroll, 80); - assert!(!scroll.stick_to_bottom); - - // Test jump to bottom - scroll.jump_to_bottom(10); - assert!(scroll.stick_to_bottom); - assert_eq!(scroll.scroll, 90); - } - - #[test] - fn test_visual_selection() { - let mut selection = VisualSelection::new(); - assert!(!selection.is_active()); - - selection.start_at((0, 0)); - assert!(selection.is_active()); - - selection.extend_to((2, 5)); - let normalized = selection.get_normalized(); - assert_eq!(normalized, Some(((0, 0), (2, 5)))); - - selection.clear(); - assert!(!selection.is_active()); - } - - #[test] - fn test_extract_text_single_line() { - let lines = vec!["Hello World".to_string()]; - let result = extract_text_from_selection(&lines, (0, 0), (0, 5)); - assert_eq!(result, Some("Hello".to_string())); - } - - #[test] - fn test_extract_text_multi_line() { - let lines = vec![ - "First line".to_string(), - "Second line".to_string(), - "Third line".to_string(), - ]; - let result = extract_text_from_selection(&lines, (0, 6), (2, 5)); - assert_eq!(result, Some("line\nSecond line\nThird".to_string())); - } - - #[test] - fn test_word_boundaries() { - let line = "hello world test"; - assert_eq!(find_next_word_boundary(line, 0), Some(5)); - assert_eq!(find_next_word_boundary(line, 5), Some(6)); - assert_eq!(find_next_word_boundary(line, 6), Some(11)); - - assert_eq!(find_prev_word_boundary(line, 16), Some(12)); - assert_eq!(find_prev_word_boundary(line, 11), Some(6)); - assert_eq!(find_prev_word_boundary(line, 6), Some(0)); - } -} diff --git a/crates/owlen-core/src/usage.rs b/crates/owlen-core/src/usage.rs deleted file mode 100644 index d936934..0000000 --- a/crates/owlen-core/src/usage.rs +++ /dev/null @@ -1,329 +0,0 @@ -use crate::{Error, Result}; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, VecDeque}; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::fs; - -const LEDGER_VERSION: u32 = 1; -const SECONDS_PER_HOUR: i64 = 60 * 60; -const SECONDS_PER_WEEK: i64 = 7 * 24 * 60 * 60; - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct UsageRecord { - timestamp: i64, - prompt_tokens: u32, - completion_tokens: u32, -} - -#[derive(Serialize, Deserialize)] -struct LedgerFile { - version: u32, - providers: HashMap>, -} - -impl Default for LedgerFile { - fn default() -> Self { - Self { - version: LEDGER_VERSION, - providers: HashMap::new(), - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct UsageLedger { - path: PathBuf, - providers: HashMap>, -} - -#[derive(Clone, Debug, Default)] -pub struct UsageQuota { - pub hourly_quota_tokens: Option, - pub weekly_quota_tokens: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum UsageWindow { - Hour, - Week, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum UsageBand { - Normal = 0, - Warning = 1, - Critical = 2, -} - -#[derive(Clone, Debug, Default)] -pub struct WindowMetrics { - pub prompt_tokens: u64, - pub completion_tokens: u64, - pub total_tokens: u64, - pub quota_tokens: Option, -} - -impl WindowMetrics { - pub fn percent_of_quota(&self) -> Option { - let quota = self.quota_tokens?; - if quota == 0 { - return None; - } - Some(self.total_tokens as f64 / quota as f64) - } - - pub fn band(&self) -> UsageBand { - match self.percent_of_quota() { - Some(p) if p >= 0.95_f64 => UsageBand::Critical, - Some(p) if p >= 0.80_f64 => UsageBand::Warning, - _ => UsageBand::Normal, - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct UsageSnapshot { - pub provider: String, - pub hourly: WindowMetrics, - pub weekly: WindowMetrics, - pub last_updated: Option, -} - -impl UsageSnapshot { - pub fn window(&self, window: UsageWindow) -> &WindowMetrics { - match window { - UsageWindow::Hour => &self.hourly, - UsageWindow::Week => &self.weekly, - } - } -} - -impl UsageLedger { - pub fn empty(path: PathBuf) -> Self { - Self { - path, - providers: HashMap::new(), - } - } - - pub async fn load_or_default(path: PathBuf) -> Result { - if !path.exists() { - return Ok(Self { - path, - providers: HashMap::new(), - }); - } - - let contents = fs::read_to_string(&path) - .await - .map_err(|err| Error::Storage(format!("Failed to read usage ledger: {err}")))?; - - let file: LedgerFile = match serde_json::from_str(&contents) { - Ok(file) => file, - Err(err) => { - return Err(Error::Storage(format!( - "Failed to parse usage ledger at {}: {err}", - path.display() - ))); - } - }; - - Ok(Self { - path, - providers: file.providers, - }) - } - - pub async fn persist(&self) -> Result<()> { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|err| Error::Storage(format!("Failed to create data directory: {err}")))?; - } - - let serialized = self.serialize()?; - - fs::write(&self.path, serialized) - .await - .map_err(|err| Error::Storage(format!("Failed to write usage ledger: {err}")))?; - - Ok(()) - } - - pub fn record( - &mut self, - provider: &str, - usage: &crate::types::TokenUsage, - timestamp: SystemTime, - ) { - let total_tokens = usage.total_tokens; - if total_tokens == 0 { - return; - } - - let ts = match timestamp.duration_since(UNIX_EPOCH) { - Ok(duration) => duration.as_secs() as i64, - Err(_) => 0, - }; - - let entry = self.providers.entry(provider.to_string()).or_default(); - - entry.push_back(UsageRecord { - timestamp: ts, - prompt_tokens: usage.prompt_tokens, - completion_tokens: usage.completion_tokens, - }); - - self.prune_old(provider, ts); - } - - pub fn provider_keys(&self) -> impl Iterator { - self.providers.keys() - } - - pub fn serialize(&self) -> Result { - let file = LedgerFile { - version: LEDGER_VERSION, - providers: self.providers.clone(), - }; - - serde_json::to_string_pretty(&file) - .map_err(|err| Error::Storage(format!("Failed to serialize usage ledger: {err}"))) - } - - pub fn path(&self) -> &Path { - &self.path - } - - pub fn snapshot(&self, provider: &str, quotas: UsageQuota, now: SystemTime) -> UsageSnapshot { - let now_secs = now - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) - .as_secs() as i64; - - let mut snapshot = UsageSnapshot { - provider: provider.to_string(), - hourly: WindowMetrics { - quota_tokens: quotas.hourly_quota_tokens, - ..Default::default() - }, - weekly: WindowMetrics { - quota_tokens: quotas.weekly_quota_tokens, - ..Default::default() - }, - last_updated: None, - }; - - if let Some(records) = self.providers.get(provider) { - for record in records { - if now_secs - record.timestamp <= SECONDS_PER_HOUR { - snapshot.hourly.prompt_tokens += record.prompt_tokens as u64; - snapshot.hourly.completion_tokens += record.completion_tokens as u64; - } - - if now_secs - record.timestamp <= SECONDS_PER_WEEK { - snapshot.weekly.prompt_tokens += record.prompt_tokens as u64; - snapshot.weekly.completion_tokens += record.completion_tokens as u64; - } - } - - snapshot.hourly.total_tokens = - snapshot.hourly.prompt_tokens + snapshot.hourly.completion_tokens; - snapshot.weekly.total_tokens = - snapshot.weekly.prompt_tokens + snapshot.weekly.completion_tokens; - - snapshot.last_updated = records.back().and_then(|record| { - UNIX_EPOCH.checked_add(Duration::from_secs(record.timestamp as u64)) - }); - } - - snapshot - } - - pub fn prune_old(&mut self, provider: &str, now_secs: i64) { - if let Some(records) = self.providers.get_mut(provider) { - while let Some(front) = records.front() { - if now_secs - front.timestamp > SECONDS_PER_WEEK { - records.pop_front(); - } else { - break; - } - } - } - } - - pub fn prune_all(&mut self, now: SystemTime) { - let now_secs = now - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) - .as_secs() as i64; - let provider_keys: Vec = self.providers.keys().cloned().collect(); - for provider in provider_keys { - self.prune_old(&provider, now_secs); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::TokenUsage; - use std::time::{Duration, UNIX_EPOCH}; - use tempfile::tempdir; - - fn make_usage(prompt: u32, completion: u32) -> TokenUsage { - TokenUsage { - prompt_tokens: prompt, - completion_tokens: completion, - total_tokens: prompt.saturating_add(completion), - } - } - - #[test] - fn records_and_summarizes_usage() { - let temp = tempdir().expect("tempdir"); - let path = temp.path().join("ledger.json"); - let mut ledger = UsageLedger::empty(path); - - let usage = make_usage(40, 10); - let timestamp = UNIX_EPOCH + Duration::from_secs(1); - ledger.record("ollama_cloud", &usage, timestamp); - - let quotas = UsageQuota { - hourly_quota_tokens: Some(100), - weekly_quota_tokens: Some(1000), - }; - - let snapshot = ledger.snapshot("ollama_cloud", quotas, UNIX_EPOCH + Duration::from_secs(2)); - - assert_eq!(snapshot.hourly.total_tokens, 50); - assert_eq!(snapshot.weekly.total_tokens, 50); - assert_eq!(snapshot.hourly.quota_tokens, Some(100)); - assert_eq!(snapshot.weekly.quota_tokens, Some(1000)); - assert_eq!(snapshot.hourly.band(), UsageBand::Normal); - } - - #[test] - fn prunes_records_outside_week() { - let temp = tempdir().expect("tempdir"); - let path = temp.path().join("ledger.json"); - let mut ledger = UsageLedger::empty(path); - - let old_usage = make_usage(30, 5); - let recent_usage = make_usage(20, 5); - - let base = UNIX_EPOCH; - ledger.record("ollama_cloud", &old_usage, base); - - // Advance beyond a week for the second record. - let later = UNIX_EPOCH + Duration::from_secs(SECONDS_PER_WEEK as u64 + 120); - ledger.record("ollama_cloud", &recent_usage, later); - - let quotas = UsageQuota::default(); - let snapshot = ledger.snapshot("ollama_cloud", quotas, later); - - assert_eq!(snapshot.hourly.total_tokens, 25); - assert_eq!(snapshot.weekly.total_tokens, 25); - } -} diff --git a/crates/owlen-core/src/validation.rs b/crates/owlen-core/src/validation.rs deleted file mode 100644 index 24f8d5e..0000000 --- a/crates/owlen-core/src/validation.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::collections::HashMap; - -use anyhow::{Context, Result}; -use jsonschema::{JSONSchema, ValidationError}; -use serde_json::{Value, json}; - -use crate::tools::WEB_SEARCH_TOOL_NAME; - -pub struct SchemaValidator { - schemas: HashMap, -} - -impl Default for SchemaValidator { - fn default() -> Self { - Self::new() - } -} - -impl SchemaValidator { - pub fn new() -> Self { - Self { - schemas: HashMap::new(), - } - } - - pub fn register_schema(&mut self, tool_name: &str, schema: Value) -> Result<()> { - let compiled = JSONSchema::compile(&schema) - .map_err(|e| anyhow::anyhow!("Invalid schema for {}: {}", tool_name, e))?; - - self.schemas.insert(tool_name.to_string(), compiled); - Ok(()) - } - - pub fn validate(&self, tool_name: &str, input: &Value) -> Result<()> { - let schema = self - .schemas - .get(tool_name) - .with_context(|| format!("No schema registered for tool: {}", tool_name))?; - - if let Err(errors) = schema.validate(input) { - let error_messages: Vec = errors.map(format_validation_error).collect(); - - return Err(anyhow::anyhow!( - "Input validation failed for {}: {}", - tool_name, - error_messages.join(", ") - )); - } - - Ok(()) - } -} - -fn format_validation_error(error: ValidationError) -> String { - format!("Validation error at {}: {}", error.instance_path, error) -} - -pub fn get_builtin_schemas() -> HashMap { - let mut schemas = HashMap::new(); - - let web_search_schema = json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "max_results": { - "type": "integer", - "minimum": 1, - "maximum": 10, - "default": 5 - } - }, - "required": ["query"], - "additionalProperties": false - }); - - schemas.insert(WEB_SEARCH_TOOL_NAME.to_string(), web_search_schema.clone()); - - schemas.insert( - "code_exec".to_string(), - json!({ - "type": "object", - "properties": { - "language": { - "type": "string", - "enum": ["python", "javascript", "bash", "rust"] - }, - "code": { - "type": "string", - "minLength": 1, - "maxLength": 10000 - }, - "timeout": { - "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 30 - } - }, - "required": ["language", "code"], - "additionalProperties": false - }), - ); - - schemas -} diff --git a/crates/owlen-core/src/wrap_cursor.rs b/crates/owlen-core/src/wrap_cursor.rs deleted file mode 100644 index 3a89657..0000000 --- a/crates/owlen-core/src/wrap_cursor.rs +++ /dev/null @@ -1,90 +0,0 @@ -#![allow(clippy::cast_possible_truncation)] - -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScreenPos { - pub row: u16, - pub col: u16, -} - -pub fn build_cursor_map(text: &str, width: u16) -> Vec { - assert!(width > 0); - let width = width as usize; - let mut pos_map = vec![ScreenPos { row: 0, col: 0 }; text.len() + 1]; - let mut row = 0; - let mut col = 0; - - let mut word_start_idx = 0; - let mut word_start_col = 0; - - for (byte_offset, grapheme) in text.grapheme_indices(true) { - let grapheme_width = UnicodeWidthStr::width(grapheme); - - if grapheme == "\n" { - row += 1; - col = 0; - word_start_col = 0; - word_start_idx = byte_offset + grapheme.len(); - // Set position for the end of this grapheme and any intermediate bytes - let end_pos = ScreenPos { - row: row as u16, - col: col as u16, - }; - for i in 1..=grapheme.len() { - if byte_offset + i < pos_map.len() { - pos_map[byte_offset + i] = end_pos; - } - } - continue; - } - - if grapheme.chars().all(char::is_whitespace) { - if col + grapheme_width > width { - // Whitespace causes wrap - row += 1; - col = 1; // Position after wrapping space - word_start_col = 1; - word_start_idx = byte_offset + grapheme.len(); - } else { - col += grapheme_width; - word_start_col = col; - word_start_idx = byte_offset + grapheme.len(); - } - } else if col + grapheme_width > width { - if word_start_col > 0 && byte_offset == word_start_idx { - // This is the first character of a new word that won't fit, wrap it - row += 1; - col = grapheme_width; - } else if word_start_col == 0 { - // No previous word boundary, hard break - row += 1; - col = grapheme_width; - } else { - // This is part of a word already on the line, let it extend beyond width - col += grapheme_width; - } - } else { - col += grapheme_width; - } - - // Set position for the end of this grapheme and any intermediate bytes - let end_pos = ScreenPos { - row: row as u16, - col: col as u16, - }; - for i in 1..=grapheme.len() { - if byte_offset + i < pos_map.len() { - pos_map[byte_offset + i] = end_pos; - } - } - } - - pos_map -} - -pub fn byte_to_screen_pos(text: &str, byte_idx: usize, width: u16) -> ScreenPos { - let pos_map = build_cursor_map(text, width); - pos_map[byte_idx.min(text.len())] -} diff --git a/crates/owlen-core/tests/agent_tool_flow.rs b/crates/owlen-core/tests/agent_tool_flow.rs deleted file mode 100644 index 37412e0..0000000 --- a/crates/owlen-core/tests/agent_tool_flow.rs +++ /dev/null @@ -1,433 +0,0 @@ -use std::{ - any::Any, - collections::HashMap, - sync::{Arc, Mutex}, -}; - -use anyhow::anyhow; -use async_trait::async_trait; -use futures::StreamExt; -use owlen_core::tools::{WEB_SEARCH_TOOL_NAME, tool_name_matches}; -use owlen_core::{ - Config, Error, Mode, Provider, - config::McpMode, - consent::ConsentScope, - mcp::{ - McpClient, McpToolCall, McpToolDescriptor, McpToolResponse, - failover::{FailoverMcpClient, ServerEntry}, - }, - session::{ControllerEvent, SessionController, SessionOutcome}, - storage::StorageManager, - types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, ToolCall}, - ui::NoOpUiController, -}; -use tempfile::tempdir; -use tokio::sync::mpsc; - -struct StreamingToolProvider; - -#[async_trait] -impl Provider for StreamingToolProvider { - fn name(&self) -> &str { - "mock-streaming-provider" - } - - async fn list_models(&self) -> owlen_core::Result> { - Ok(vec![ModelInfo { - id: "mock-model".into(), - name: "Mock Model".into(), - description: Some("A mock model that emits tool calls".into()), - provider: self.name().into(), - context_window: Some(4096), - capabilities: vec!["chat".into(), "tools".into()], - supports_tools: true, - }]) - } - - async fn send_prompt(&self, _request: ChatRequest) -> owlen_core::Result { - let mut message = Message::assistant("tool-call".to_string()); - message.tool_calls = Some(vec![ToolCall { - id: "call-1".to_string(), - name: "resources_write".to_string(), - arguments: serde_json::json!({"path": "README.md", "content": "hello"}), - }]); - - Ok(ChatResponse { - message, - usage: None, - is_streaming: false, - is_final: true, - }) - } - - async fn stream_prompt( - &self, - _request: ChatRequest, - ) -> owlen_core::Result { - let mut first_chunk = Message::assistant( - "Thought: need to update README.\nAction: resources/write".to_string(), - ); - first_chunk.tool_calls = Some(vec![ToolCall { - id: "call-1".to_string(), - name: "resources_write".to_string(), - arguments: serde_json::json!({"path": "README.md", "content": "hello"}), - }]); - - let chunk = ChatResponse { - message: first_chunk, - usage: None, - is_streaming: true, - is_final: false, - }; - - Ok(Box::pin(futures::stream::iter(vec![Ok(chunk)]))) - } - - async fn health_check(&self) -> owlen_core::Result<()> { - Ok(()) - } - - fn as_any(&self) -> &(dyn Any + Send + Sync) { - self - } -} - -struct NoToolSupportProvider { - captured: Arc>>, -} - -impl NoToolSupportProvider { - fn new() -> Self { - Self { - captured: Arc::new(Mutex::new(None)), - } - } - - fn take_captured(&self) -> Option { - self.captured.lock().expect("capture mutex").take() - } -} - -#[async_trait] -impl Provider for NoToolSupportProvider { - fn name(&self) -> &str { - "mock-tool-less-provider" - } - - async fn list_models(&self) -> owlen_core::Result> { - Ok(vec![ModelInfo { - id: "tool-less-model".into(), - name: "Toolless Model".into(), - description: Some("A model without tool support.".into()), - provider: self.name().into(), - context_window: Some(4096), - capabilities: vec!["chat".into()], - supports_tools: false, - }]) - } - - async fn send_prompt(&self, request: ChatRequest) -> owlen_core::Result { - { - let mut guard = self.captured.lock().expect("capture mutex"); - *guard = Some(request); - } - - Ok(ChatResponse { - message: Message::assistant("ack".to_string()), - usage: None, - is_streaming: false, - is_final: true, - }) - } - - async fn stream_prompt( - &self, - _request: ChatRequest, - ) -> owlen_core::Result { - Err(Error::Provider(anyhow!( - "streaming disabled for mock provider" - ))) - } - - async fn health_check(&self) -> owlen_core::Result<()> { - Ok(()) - } - - fn as_any(&self) -> &(dyn Any + Send + Sync) { - self - } -} - -fn tool_descriptor() -> McpToolDescriptor { - McpToolDescriptor { - name: WEB_SEARCH_TOOL_NAME.to_string(), - description: "search".to_string(), - input_schema: serde_json::json!({"type": "object"}), - requires_network: true, - requires_filesystem: vec![], - } -} - -struct TimeoutClient; - -#[async_trait] -impl McpClient for TimeoutClient { - async fn list_tools(&self) -> owlen_core::Result> { - Ok(vec![tool_descriptor()]) - } - - async fn call_tool(&self, _call: McpToolCall) -> owlen_core::Result { - Err(Error::Network( - "timeout while contacting remote web search endpoint".into(), - )) - } -} - -#[derive(Clone)] -struct CachedResponseClient { - response: Arc, -} - -impl CachedResponseClient { - fn new() -> Self { - let mut metadata = HashMap::new(); - metadata.insert("source".to_string(), "cache".to_string()); - metadata.insert("cached".to_string(), "true".to_string()); - - let response = McpToolResponse { - name: WEB_SEARCH_TOOL_NAME.to_string(), - success: true, - output: serde_json::json!({ - "query": "rust", - "results": [ - {"title": "Rust Programming Language", "url": "https://www.rust-lang.org"} - ], - "note": "cached result" - }), - metadata, - duration_ms: 0, - }; - - Self { - response: Arc::new(response), - } - } -} - -#[async_trait] -impl McpClient for CachedResponseClient { - async fn list_tools(&self) -> owlen_core::Result> { - Ok(vec![tool_descriptor()]) - } - - async fn call_tool(&self, _call: McpToolCall) -> owlen_core::Result { - Ok((*self.response).clone()) - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn streaming_file_write_consent_denied_returns_resolution() { - let temp_dir = tempdir().expect("temp dir"); - let storage = StorageManager::with_database_path(temp_dir.path().join("owlen-tests.db")) - .await - .expect("storage"); - - let mut config = Config::default(); - config.general.enable_streaming = true; - config.privacy.encrypt_local_data = false; - config.privacy.require_consent_per_session = true; - config.general.default_model = Some("mock-model".into()); - config.mcp.mode = McpMode::LocalOnly; - config - .refresh_mcp_servers(None) - .expect("refresh MCP servers"); - - let provider: Arc = Arc::new(StreamingToolProvider); - let ui = Arc::new(NoOpUiController); - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - - let mut session = SessionController::new( - provider, - config, - Arc::new(storage), - ui, - true, - Some(event_tx), - ) - .await - .expect("session controller"); - - session - .set_operating_mode(Mode::Code) - .await - .expect("code mode"); - - let outcome = session - .send_message( - "Please write to README".to_string(), - ChatParameters { - stream: true, - ..Default::default() - }, - ) - .await - .expect("send message"); - - let (response_id, mut stream) = if let SessionOutcome::Streaming { - response_id, - stream, - } = outcome - { - (response_id, stream) - } else { - panic!("expected streaming outcome"); - }; - - session - .mark_stream_placeholder(response_id, "▌") - .expect("placeholder"); - - let chunk = stream - .next() - .await - .expect("stream chunk") - .expect("chunk result"); - session - .apply_stream_chunk(response_id, &chunk) - .expect("apply chunk"); - - let tool_calls = session - .check_streaming_tool_calls(response_id) - .expect("tool calls"); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].name, "resources_write"); - - let request_id = loop { - match event_rx.recv().await.expect("controller event") { - ControllerEvent::ToolRequested { - request_id, - tool_name, - data_types, - endpoints, - .. - } => { - assert_eq!(tool_name, "resources_write"); - assert!(data_types.iter().any(|t| t.contains("file"))); - assert!(endpoints.iter().any(|e| e.contains("filesystem"))); - break request_id; - } - ControllerEvent::CompressionCompleted { .. } => continue, - } - }; - - let resolution = session - .resolve_tool_consent(request_id, ConsentScope::Denied) - .expect("resolution"); - assert_eq!(resolution.scope, ConsentScope::Denied); - assert_eq!(resolution.tool_name, "resources_write"); - assert_eq!(resolution.tool_calls.len(), tool_calls.len()); - - let err = session - .resolve_tool_consent(request_id, ConsentScope::Denied) - .expect_err("second resolution should fail"); - matches!(err, Error::InvalidInput(_)); - - let conversation = session.conversation().clone(); - let assistant = conversation - .messages - .iter() - .find(|message| message.role == Role::Assistant) - .expect("assistant message present"); - assert!( - assistant - .tool_calls - .as_ref() - .and_then(|calls| calls.first()) - .is_some_and(|call| call.name == "resources_write"), - "stream chunk should capture the tool call on the assistant message" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn disables_tools_when_model_lacks_support() { - let raw_provider = Arc::new(NoToolSupportProvider::new()); - let provider: Arc = raw_provider.clone(); - - let temp_dir = tempdir().expect("temp dir"); - let storage = StorageManager::with_database_path(temp_dir.path().join("owlen-tests.db")) - .await - .expect("storage"); - - let mut config = Config::default(); - config.general.default_provider = "mock-tool-less-provider".into(); - config.general.default_model = Some("tool-less-model".into()); - config.general.enable_streaming = false; - config.privacy.encrypt_local_data = false; - config.privacy.require_consent_per_session = false; - config.mcp.mode = McpMode::LocalOnly; - config - .refresh_mcp_servers(None) - .expect("refresh MCP servers"); - - let ui = Arc::new(NoOpUiController); - let mut session = SessionController::new(provider, config, Arc::new(storage), ui, false, None) - .await - .expect("session controller"); - - let outcome = session - .send_message( - "Please respond without tools.".to_string(), - ChatParameters::default(), - ) - .await - .expect("send_message should succeed"); - - if let SessionOutcome::Complete(response) = outcome { - assert_eq!(response.message.content, "ack"); - } else { - panic!("expected complete outcome when sending prompt"); - } - - let captured = raw_provider - .take_captured() - .expect("provider should capture chat request"); - assert!( - captured.tools.is_none(), - "tools should be disabled when the selected model does not support tools" - ); -} - -#[tokio::test] -async fn web_tool_timeout_fails_over_to_cached_result() { - let primary: Arc = Arc::new(TimeoutClient); - let cached = CachedResponseClient::new(); - let backup: Arc = Arc::new(cached.clone()); - - let client = FailoverMcpClient::with_servers(vec![ - ServerEntry::new("primary".into(), primary, 1), - ServerEntry::new("cache".into(), backup, 2), - ]); - - let call = McpToolCall { - name: WEB_SEARCH_TOOL_NAME.to_string(), - arguments: serde_json::json!({ "query": "rust", "max_results": 3 }), - }; - - let response = client.call_tool(call.clone()).await.expect("fallback"); - - assert!(tool_name_matches(&response.name, WEB_SEARCH_TOOL_NAME)); - assert_eq!( - response.metadata.get("source").map(String::as_str), - Some("cache") - ); - assert_eq!( - response.output.get("note").and_then(|value| value.as_str()), - Some("cached result") - ); - - let statuses = client.get_server_status().await; - assert!(statuses.iter().any(|(name, health)| name == "primary" - && !matches!(health, owlen_core::mcp::failover::ServerHealth::Healthy))); - assert!(statuses.iter().any(|(name, health)| name == "cache" - && matches!(health, owlen_core::mcp::failover::ServerHealth::Healthy))); -} diff --git a/crates/owlen-core/tests/compression.rs b/crates/owlen-core/tests/compression.rs deleted file mode 100644 index b191542..0000000 --- a/crates/owlen-core/tests/compression.rs +++ /dev/null @@ -1,146 +0,0 @@ -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use futures::stream; -use owlen_core::config::{CompressionStrategy, Config}; -use owlen_core::session::SessionController; -use owlen_core::storage::StorageManager; -use owlen_core::types::{ChatRequest, ChatResponse, Message, ModelInfo, Role}; -use owlen_core::ui::NoOpUiController; -use owlen_core::{ChatStream, Provider, Result as CoreResult}; -use tempfile::tempdir; - -fn make_session_config(strategy: CompressionStrategy, auto: bool) -> Config { - let mut config = Config::default(); - config.general.default_model = Some("stub-model".into()); - config.general.enable_streaming = false; - config.chat.strategy = strategy; - config.chat.auto_compress = auto; - config.chat.trigger_tokens = 64; - config.chat.retain_recent_messages = 2; - config -} - -async fn build_session(config: Config) -> Result { - let temp_dir = tempdir().expect("temp dir"); - let storage = Arc::new( - StorageManager::with_database_path(temp_dir.path().join("owlen-compression-tests.db")) - .await - .expect("storage"), - ); - let provider: Arc = Arc::new(StubProvider); - let ui = Arc::new(NoOpUiController); - SessionController::new(provider, config, storage, ui, false, None) - .await - .map_err(|err| anyhow!(err)) -} - -struct StubProvider; - -#[async_trait] -impl Provider for StubProvider { - fn name(&self) -> &str { - "stub-provider" - } - - async fn list_models(&self) -> CoreResult> { - Ok(vec![ModelInfo { - id: "stub-model".into(), - name: "Stub Model".into(), - description: Some("Stub provider model".into()), - provider: "stub-provider".into(), - context_window: Some(8_192), - capabilities: vec!["chat".into()], - supports_tools: false, - }]) - } - - async fn send_prompt(&self, _request: ChatRequest) -> CoreResult { - Ok(ChatResponse { - message: Message::assistant("stub completion".into()), - usage: None, - is_streaming: false, - is_final: true, - }) - } - - async fn stream_prompt(&self, _request: ChatRequest) -> CoreResult { - Ok(Box::pin(stream::empty())) - } - - async fn health_check(&self) -> CoreResult<()> { - Ok(()) - } - - fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) { - self - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn compression_compacts_history() -> Result<()> { - let mut session = build_session(make_session_config(CompressionStrategy::Local, true)).await?; - - for idx in 0..6 { - session.conversation_mut().push_user_message(format!( - "User request #{idx}: Explain the subsystem in detail." - )); - session.conversation_mut().push_assistant_message(format!( - "Assistant reply #{idx}: Provided detailed explanation with follow-up tasks." - )); - } - - let before_len = session.conversation().messages.len(); - assert!( - before_len > 6, - "expected longer transcript before compression" - ); - - let report = session - .compress_now() - .await? - .expect("compression should trigger"); - assert!( - !report.automated, - "manual compression should flag automated = false" - ); - assert!(report.compressed_messages > 0); - assert!(report.estimated_tokens_after < report.estimated_tokens_before); - - let after = session.conversation(); - assert!(after.messages.len() < before_len); - let first = after - .messages - .first() - .expect("summary message should exist after compression"); - assert_eq!(first.role, Role::System); - assert!( - first.metadata.contains_key("compression"), - "summary message must include metadata" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn auto_compress_respects_toggle() -> Result<()> { - let mut session = build_session(make_session_config(CompressionStrategy::Local, false)).await?; - - for idx in 0..5 { - session - .conversation_mut() - .push_user_message(format!("Message {idx} from user.")); - session - .conversation_mut() - .push_assistant_message(format!("Assistant reply {idx}.")); - } - - let result = session.maybe_auto_compress().await?; - assert!( - result.is_none(), - "auto compression should skip when disabled" - ); - - Ok(()) -} diff --git a/crates/owlen-core/tests/consent_scope.rs b/crates/owlen-core/tests/consent_scope.rs deleted file mode 100644 index fa93a6a..0000000 --- a/crates/owlen-core/tests/consent_scope.rs +++ /dev/null @@ -1,100 +0,0 @@ -use owlen_core::consent::{ConsentManager, ConsentScope}; -use owlen_core::tools::WEB_SEARCH_TOOL_NAME; - -#[test] -fn test_consent_scopes() { - let mut manager = ConsentManager::new(); - - // Test session consent - manager.grant_consent_with_scope( - "test_tool", - vec!["data".to_string()], - vec!["https://example.com".to_string()], - ConsentScope::Session, - ); - - assert!(manager.has_consent("test_tool")); - - // Clear session consent and verify it's gone - manager.clear_session_consent(); - assert!(!manager.has_consent("test_tool")); - - // Test permanent consent survives session clear - manager.grant_consent_with_scope( - "test_tool_permanent", - vec!["data".to_string()], - vec!["https://example.com".to_string()], - ConsentScope::Permanent, - ); - - assert!(manager.has_consent("test_tool_permanent")); - manager.clear_session_consent(); - assert!(manager.has_consent("test_tool_permanent")); - - // Verify revoke works for permanent consent - manager.revoke_consent("test_tool_permanent"); - assert!(!manager.has_consent("test_tool_permanent")); -} - -#[test] -fn test_pending_requests_prevents_duplicates() { - let mut manager = ConsentManager::new(); - - // Simulate concurrent consent requests by checking pending state - // In real usage, multiple threads would call request_consent simultaneously - - // First, verify a tool has no consent - assert!(!manager.has_consent(WEB_SEARCH_TOOL_NAME)); - - // The pending_requests map is private, but we can test the behavior - // by checking that consent checks work correctly - assert!(manager.check_consent_needed(WEB_SEARCH_TOOL_NAME).is_some()); - - // Grant session consent - manager.grant_consent_with_scope( - WEB_SEARCH_TOOL_NAME, - vec!["search queries".to_string()], - vec!["https://api.search.com".to_string()], - ConsentScope::Session, - ); - - // Now it should have consent - assert!(manager.has_consent(WEB_SEARCH_TOOL_NAME)); - assert!(manager.check_consent_needed(WEB_SEARCH_TOOL_NAME).is_none()); -} - -#[test] -fn test_consent_record_separation() { - let mut manager = ConsentManager::new(); - - // Add permanent consent - manager.grant_consent_with_scope( - "perm_tool", - vec!["data".to_string()], - vec!["https://perm.com".to_string()], - ConsentScope::Permanent, - ); - - // Add session consent - manager.grant_consent_with_scope( - "session_tool", - vec!["data".to_string()], - vec!["https://session.com".to_string()], - ConsentScope::Session, - ); - - // Both should have consent - assert!(manager.has_consent("perm_tool")); - assert!(manager.has_consent("session_tool")); - - // Clear session consent - manager.clear_session_consent(); - - // Only permanent should remain - assert!(manager.has_consent("perm_tool")); - assert!(!manager.has_consent("session_tool")); - - // Clear all - manager.clear_all_consent(); - assert!(!manager.has_consent("perm_tool")); -} diff --git a/crates/owlen-core/tests/file_server.rs b/crates/owlen-core/tests/file_server.rs deleted file mode 100644 index 165dc93..0000000 --- a/crates/owlen-core/tests/file_server.rs +++ /dev/null @@ -1,52 +0,0 @@ -use owlen_core::McpToolCall; -use owlen_core::mcp::remote_client::RemoteMcpClient; -use std::fs::File; -use std::io::Write; -use tempfile::tempdir; - -#[tokio::test] -async fn remote_file_server_read_and_list() { - // Create temporary directory with a file - let dir = tempdir().expect("tempdir failed"); - let file_path = dir.path().join("hello.txt"); - let mut file = File::create(&file_path).expect("create file"); - writeln!(file, "world").expect("write file"); - - // Change current directory for the test process so the server sees the temp dir as its root - std::env::set_current_dir(dir.path()).expect("set cwd"); - - // Ensure the MCP server binary is built. - // Build the MCP server binary using the workspace manifest. - let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join("Cargo.toml"); - let build_status = std::process::Command::new("cargo") - .args(["build", "-p", "owlen-mcp-server", "--manifest-path"]) - .arg(manifest_path) - .status() - .expect("failed to run cargo build for MCP server"); - assert!(build_status.success(), "MCP server build failed"); - - // Spawn remote client after the cwd is set and binary built - let client = RemoteMcpClient::new().await.expect("remote client init"); - - // Read file via MCP - let call = McpToolCall { - name: "resources_get".to_string(), - arguments: serde_json::json!({"path": "hello.txt"}), - }; - let resp = client.call_tool(call).await.expect("call_tool"); - let content: String = serde_json::from_value(resp.output).expect("parse output"); - assert!(content.trim().ends_with("world")); - - // List directory via MCP - let list_call = McpToolCall { - name: "resources_list".to_string(), - arguments: serde_json::json!({"path": "."}), - }; - let list_resp = client.call_tool(list_call).await.expect("list_tool"); - let entries: Vec = serde_json::from_value(list_resp.output).expect("parse list"); - assert!(entries.contains(&"hello.txt".to_string())); - - // Cleanup handled by tempdir -} diff --git a/crates/owlen-core/tests/file_write.rs b/crates/owlen-core/tests/file_write.rs deleted file mode 100644 index 674cbd8..0000000 --- a/crates/owlen-core/tests/file_write.rs +++ /dev/null @@ -1,69 +0,0 @@ -use owlen_core::McpToolCall; -use owlen_core::mcp::remote_client::RemoteMcpClient; -use tempfile::tempdir; - -#[tokio::test] -async fn remote_write_and_delete() { - // Build the server binary first - let status = std::process::Command::new("cargo") - .args(["build", "-p", "owlen-mcp-server"]) - .status() - .expect("failed to build MCP server"); - assert!(status.success()); - - // Use a temp dir as project root - let dir = tempdir().expect("tempdir"); - std::env::set_current_dir(dir.path()).expect("set cwd"); - - let client = RemoteMcpClient::new().await.expect("client init"); - - // Write a file via MCP - let write_call = McpToolCall { - name: "resources_write".to_string(), - arguments: serde_json::json!({ "path": "test.txt", "content": "hello" }), - }; - client.call_tool(write_call).await.expect("write tool"); - - // Verify content via local read (fallback check) - let content = std::fs::read_to_string(dir.path().join("test.txt")).expect("read back"); - assert_eq!(content, "hello"); - - // Delete the file via MCP - let del_call = McpToolCall { - name: "resources_delete".to_string(), - arguments: serde_json::json!({ "path": "test.txt" }), - }; - client.call_tool(del_call).await.expect("delete tool"); - assert!(!dir.path().join("test.txt").exists()); -} - -#[tokio::test] -async fn write_outside_root_is_rejected() { - // Build server (already built in previous test, but ensure it exists) - let status = std::process::Command::new("cargo") - .args(["build", "-p", "owlen-mcp-server"]) - .status() - .expect("failed to build MCP server"); - assert!(status.success()); - - // Set cwd to a fresh temp dir - let dir = tempdir().expect("tempdir"); - std::env::set_current_dir(dir.path()).expect("set cwd"); - let client = RemoteMcpClient::new().await.expect("client init"); - - // Attempt to write outside the root using "../evil.txt" - let call = McpToolCall { - name: "resources_write".to_string(), - arguments: serde_json::json!({ "path": "../evil.txt", "content": "bad" }), - }; - let err = client.call_tool(call).await.unwrap_err(); - // The server returns a Network error with path traversal message - let err_str = format!("{err}"); - assert!( - err_str.contains("path traversal") - || err_str.contains("Path traversal") - || err_str.contains("escapes workspace boundary"), - "Expected path traversal error, got: {}", - err_str - ); -} diff --git a/crates/owlen-core/tests/fixtures/README.md b/crates/owlen-core/tests/fixtures/README.md deleted file mode 100644 index 95ef053..0000000 --- a/crates/owlen-core/tests/fixtures/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Integration Fixtures - -This directory holds canned Ollama responses used by the Wiremock-backed -integration tests in `ollama_wiremock.rs`. Each file mirrors the JSON payloads -served by the real Ollama HTTP API so the tests can stub -`/api/tags`, `/api/chat`, and `/v1/web/search` without contacting the network. - -- `ollama_tags.json` – minimal model listing shared by the local and cloud scenarios. -- `ollama_local_completion.json` – non-streaming completion for the local provider. -- `ollama_cloud_tool_call.json` – first chat turn that requests the `web_search` tool. -- `ollama_cloud_final.json` – follow-up completion after the tool result is injected. diff --git a/crates/owlen-core/tests/fixtures/ollama_cloud_final.json b/crates/owlen-core/tests/fixtures/ollama_cloud_final.json deleted file mode 100644 index 65ae6ca..0000000 --- a/crates/owlen-core/tests/fixtures/ollama_cloud_final.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "model": "llama3:8b-cloud", - "created_at": "2025-10-23T12:05:05Z", - "message": { - "role": "assistant", - "content": "Rust 1.85 shipped today. Summarising the highlights now.", - "tool_calls": [] - }, - "done": true, - "total_duration": 2500000000, - "load_duration": 60000000, - "prompt_eval_count": 64, - "prompt_eval_duration": 420000000, - "eval_count": 48, - "eval_duration": 520000000 -} diff --git a/crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json b/crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json deleted file mode 100644 index 48ca5e8..0000000 --- a/crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "model": "llama3:8b-cloud", - "created_at": "2025-10-23T12:05:00Z", - "message": { - "role": "assistant", - "content": "Let me check the latest Rust updates.", - "tool_calls": [ - { - "function": { - "name": "web_search", - "arguments": { - "query": "latest Rust release", - "max_results": 5 - } - } - } - ] - }, - "done": false -} diff --git a/crates/owlen-core/tests/fixtures/ollama_local_completion.json b/crates/owlen-core/tests/fixtures/ollama_local_completion.json deleted file mode 100644 index d2b50e9..0000000 --- a/crates/owlen-core/tests/fixtures/ollama_local_completion.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "model": "local-mini", - "created_at": "2025-10-23T12:00:00Z", - "message": { - "role": "assistant", - "content": "Local response complete.", - "tool_calls": [] - }, - "done": true, - "total_duration": 1200000000, - "load_duration": 50000000, - "prompt_eval_count": 24, - "prompt_eval_duration": 320000000, - "eval_count": 12, - "eval_duration": 480000000 -} diff --git a/crates/owlen-core/tests/fixtures/ollama_tags.json b/crates/owlen-core/tests/fixtures/ollama_tags.json deleted file mode 100644 index 493bdd9..0000000 --- a/crates/owlen-core/tests/fixtures/ollama_tags.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "models": [ - { - "name": "local-mini", - "size": 1048576, - "digest": "local-digest", - "modified_at": "2025-10-23T00:00:00Z", - "details": { - "format": "gguf", - "family": "llama3", - "parameter_size": "3B", - "quantization_level": "Q4" - } - }, - { - "name": "llama3:8b-cloud", - "size": 8589934592, - "digest": "cloud-digest", - "modified_at": "2025-10-23T00:05:00Z", - "details": { - "format": "gguf", - "family": "llama3", - "parameter_size": "8B", - "quantization_level": "Q4" - } - } - ] -} diff --git a/crates/owlen-core/tests/long_word_debug.rs b/crates/owlen-core/tests/long_word_debug.rs deleted file mode 100644 index e814263..0000000 --- a/crates/owlen-core/tests/long_word_debug.rs +++ /dev/null @@ -1,115 +0,0 @@ -use owlen_core::wrap_cursor::build_cursor_map; - -#[test] -fn debug_long_word_wrapping() { - // Test the exact scenario from the user's issue - let text = "asdnklasdnaklsdnkalsdnaskldaskldnaskldnaskldnaskldnaskldnaskldnaskld asdnklska dnskadl dasnksdl asdn"; - let width = 50; // Approximate width from the user's example - - println!("Testing long word text with width {}", width); - println!("Text: '{}'", text); - - // Check what the cursor map shows - let cursor_map = build_cursor_map(text, width); - - println!("\nCursor map for key positions:"); - let long_word_end = text.find(' ').unwrap_or(text.len()); - for i in [ - 0, - 10, - 20, - 30, - 40, - 50, - 60, - 70, - long_word_end, - long_word_end + 1, - text.len(), - ] { - if i <= text.len() { - let pos = cursor_map[i]; - let char_at = if i < text.len() { - format!("'{}'", text.chars().nth(i).unwrap_or('?')) - } else { - "END".to_string() - }; - println!( - " Byte {}: {} -> row {}, col {}", - i, char_at, pos.row, pos.col - ); - } - } - - // Test what my formatting function produces - let lines = format_text_with_word_wrap_debug(text, width); - - println!("\nFormatted lines:"); - for (i, line) in lines.iter().enumerate() { - println!(" Line {}: '{}' (length: {})", i, line, line.len()); - } - - // The long word should be broken up, not kept on one line - assert!( - lines[0].len() <= width as usize + 5, - "First line is too long: {} chars", - lines[0].len() - ); -} - -fn format_text_with_word_wrap_debug(text: &str, width: u16) -> Vec { - if text.is_empty() { - return vec!["".to_string()]; - } - - // Use the cursor map to determine where line breaks should occur - let cursor_map = build_cursor_map(text, width); - - let mut lines = Vec::new(); - let mut current_line = String::new(); - let mut current_row = 0; - - for (byte_idx, ch) in text.char_indices() { - let pos_before = if byte_idx > 0 { - cursor_map[byte_idx] - } else { - cursor_map[0] - }; - let pos_after = cursor_map[byte_idx + ch.len_utf8()]; - - println!( - "Processing '{}' at byte {}: before=({},{}) after=({},{})", - ch, byte_idx, pos_before.row, pos_before.col, pos_after.row, pos_after.col - ); - - // If the row changed, we need to start a new line - if pos_after.row > current_row { - println!( - " Row changed from {} to {}! Finishing line: '{}'", - current_row, pos_after.row, current_line - ); - if !current_line.is_empty() { - lines.push(current_line.clone()); - current_line.clear(); - } - current_row = pos_after.row; - - // If this character is a space that caused the wrap, don't include it - if ch.is_whitespace() && pos_before.row < pos_after.row { - println!(" Skipping wrapping space"); - continue; // Skip the wrapping space - } - } - - current_line.push(ch); - } - - // Add the final line - if !current_line.is_empty() { - lines.push(current_line); - } else if lines.is_empty() { - lines.push("".to_string()); - } - - lines -} diff --git a/crates/owlen-core/tests/mcp_timeout.rs b/crates/owlen-core/tests/mcp_timeout.rs deleted file mode 100644 index 74e6f31..0000000 --- a/crates/owlen-core/tests/mcp_timeout.rs +++ /dev/null @@ -1,271 +0,0 @@ -use owlen_core::config::McpServerConfig; -use owlen_core::mcp::remote_client::RemoteMcpClient; -use owlen_core::{Error, McpToolCall}; -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Command, Stdio}; -use std::time::Duration; -use tempfile::tempdir; - -/// Test that the timeout mechanism triggers for slow operations. -/// This test spawns a mock MCP server that intentionally delays responses -/// to verify timeout behavior. -#[tokio::test] -async fn test_rpc_timeout_triggers() { - // Create a simple mock server script that delays responses - let dir = tempdir().expect("tempdir failed"); - let script_path = dir.path().join("slow_server.sh"); - - // Create a bash script that echoes valid JSON-RPC but with delay - let script_content = r#"#!/bin/bash -while IFS= read -r line; do - # Sleep for 5 seconds to simulate a slow server - sleep 5 - # Echo back a simple response - echo '{"jsonrpc":"2.0","id":1,"result":{}}' -done -"#; - - std::fs::write(&script_path, script_content).expect("write script"); - - // Make script executable on Unix systems - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&script_path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&script_path, perms).unwrap(); - } - - // Create config with a 2-second timeout - let config = McpServerConfig { - name: "slow_server".to_string(), - command: script_path.to_string_lossy().to_string(), - args: vec![], - transport: "stdio".to_string(), - env: HashMap::new(), - oauth: None, - rpc_timeout_secs: Some(2), // 2 second timeout - }; - - // Create client - let client = RemoteMcpClient::new_with_config(&config) - .await - .expect("client creation"); - - // Attempt to list tools - should timeout after 2 seconds (or 10 for list operations) - // Since we set default to 2, list operations will use the minimum of the two - let start = std::time::Instant::now(); - let result = client.list_tools().await; - let elapsed = start.elapsed(); - - // Verify that the operation timed out - assert!(result.is_err(), "Expected timeout error"); - - if let Err(Error::Timeout(msg)) = result { - assert!( - msg.contains("timed out"), - "Error message should mention timeout" - ); - assert!( - msg.contains("tools/list"), - "Error message should mention the method" - ); - } else { - panic!("Expected Error::Timeout, got: {:?}", result); - } - - // Verify timeout happened around the expected time (with some tolerance) - // List operations use 10s timeout, but we configured 2s default - // The list operation should use 10s from get_timeout_for_method - assert!( - elapsed >= Duration::from_secs(9) && elapsed <= Duration::from_secs(12), - "Timeout should occur around 10 seconds, got: {:?}", - elapsed - ); -} - -/// Test that fast operations complete before timeout -#[tokio::test] -async fn test_rpc_completes_before_timeout() { - // Ensure the MCP server binary is built - let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join("Cargo.toml"); - let build_status = Command::new("cargo") - .args(["build", "-p", "owlen-mcp-server", "--manifest-path"]) - .arg(manifest_path) - .status() - .expect("failed to run cargo build"); - assert!(build_status.success(), "MCP server build failed"); - - // Use the real server with a reasonable timeout - let client = RemoteMcpClient::new().await.expect("client creation"); - - // This should complete well before any timeout - let start = std::time::Instant::now(); - let result = client.list_tools().await; - let elapsed = start.elapsed(); - - // Should succeed - assert!(result.is_ok(), "Expected success, got: {:?}", result); - - // Should complete quickly (well before 10s timeout for list operations) - assert!( - elapsed < Duration::from_secs(5), - "Operation should complete quickly, took: {:?}", - elapsed - ); -} - -/// Test custom timeout configuration -#[tokio::test] -async fn test_custom_timeout_configuration() { - let dir = tempdir().expect("tempdir failed"); - let script_path = dir.path().join("slow_server.sh"); - - // Server that delays 3 seconds - let script_content = r#"#!/bin/bash -while IFS= read -r line; do - sleep 3 - echo '{"jsonrpc":"2.0","id":1,"result":{}}' -done -"#; - - std::fs::write(&script_path, script_content).expect("write script"); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&script_path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&script_path, perms).unwrap(); - } - - // Test with 5-second timeout - should succeed - let config_success = McpServerConfig { - name: "slow_server".to_string(), - command: script_path.to_string_lossy().to_string(), - args: vec![], - transport: "stdio".to_string(), - env: HashMap::new(), - oauth: None, - rpc_timeout_secs: Some(5), - }; - - let client_success = RemoteMcpClient::new_with_config(&config_success) - .await - .expect("client creation"); - - // This should succeed with 5s timeout (server delays 3s) - // Note: tools/list has 10s timeout, but this will be overridden by the default of 5s - // Actually, tools/list uses hardcoded 10s, so we need to call a different method - // Let's use initialize which uses 60s by default - - // Since list operations are hardcoded to 10s, and our server delays 3s, - // this should succeed regardless - let result = client_success.list_tools().await; - assert!(result.is_ok(), "Should succeed with sufficient timeout"); - - // Test with 1-second timeout - should fail - let config_fail = McpServerConfig { - name: "slow_server".to_string(), - command: script_path.to_string_lossy().to_string(), - args: vec![], - transport: "stdio".to_string(), - env: HashMap::new(), - oauth: None, - rpc_timeout_secs: Some(1), - }; - - let client_fail = RemoteMcpClient::new_with_config(&config_fail) - .await - .expect("client creation"); - - // But tools/list is hardcoded to 10s, so it won't timeout with 1s default - // We need to test a method that uses the default timeout - // Let's skip this part as the architecture uses method-specific timeouts - - // Note: The current implementation has hardcoded timeouts per operation type - // which override the configured default. This is by design for safety. -} - -/// Test that timeout errors include helpful information -#[tokio::test] -async fn test_timeout_error_messages() { - let dir = tempdir().expect("tempdir failed"); - let script_path = dir.path().join("hanging_server.sh"); - - // Server that never responds - let script_content = r#"#!/bin/bash -while IFS= read -r line; do - sleep 999999 -done -"#; - - std::fs::write(&script_path, script_content).expect("write script"); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&script_path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&script_path, perms).unwrap(); - } - - let config = McpServerConfig { - name: "hanging_server".to_string(), - command: script_path.to_string_lossy().to_string(), - args: vec![], - transport: "stdio".to_string(), - env: HashMap::new(), - oauth: None, - rpc_timeout_secs: Some(2), - }; - - let client = RemoteMcpClient::new_with_config(&config) - .await - .expect("client creation"); - - // Try to list tools - will timeout - let result = client.list_tools().await; - - assert!(result.is_err()); - - if let Err(Error::Timeout(msg)) = result { - // Verify error message contains useful information - assert!(msg.contains("tools/list"), "Should include method name"); - assert!(msg.contains("timed out"), "Should indicate timeout"); - assert!( - msg.contains("10s"), - "Should show timeout duration (10s for list ops)" - ); - } else { - panic!("Expected Error::Timeout"); - } -} - -/// Test that default timeout is applied when not configured -#[tokio::test] -async fn test_default_timeout_applied() { - // Ensure the MCP server binary is built - let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join("Cargo.toml"); - let build_status = Command::new("cargo") - .args(["build", "-p", "owlen-mcp-server", "--manifest-path"]) - .arg(manifest_path) - .status() - .expect("failed to run cargo build"); - assert!(build_status.success()); - - // Use legacy constructor which doesn't specify timeout - let client = RemoteMcpClient::new().await.expect("client creation"); - - // Should use default 30s timeout for non-specific operations - // and 10s for list operations - let result = client.list_tools().await; - - // Should succeed with default timeouts - assert!(result.is_ok()); -} diff --git a/crates/owlen-core/tests/mode_tool_filter.rs b/crates/owlen-core/tests/mode_tool_filter.rs deleted file mode 100644 index 5948dda..0000000 --- a/crates/owlen-core/tests/mode_tool_filter.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Tests for mode‑based tool availability filtering. -//! -//! These tests verify that `ToolRegistry::execute` respects the -//! `ModeConfig` settings in `Config`. The default configuration only -//! allows `web_search` in chat mode and all tools in code mode. -//! -//! We create a simple mock tool (`EchoTool`) that just echoes the -//! provided arguments. By customizing the `Config` we can test both the -//! allowed‑in‑chat and disallowed‑in‑any‑mode paths. - -use std::sync::Arc; - -use owlen_core::config::Config; -use owlen_core::mode::{Mode, ModeConfig, ModeToolConfig}; -use owlen_core::tools::registry::ToolRegistry; -use owlen_core::tools::{Tool, ToolResult, WEB_SEARCH_TOOL_NAME}; -use owlen_core::ui::{NoOpUiController, UiController}; -use serde_json::json; -use tokio::sync::Mutex; - -/// A trivial tool that returns the provided arguments as its output. -#[derive(Debug)] -struct EchoTool; - -#[async_trait::async_trait] -impl Tool for EchoTool { - fn name(&self) -> &'static str { - "echo" - } - fn description(&self) -> &'static str { - "Echo the input arguments" - } - fn schema(&self) -> serde_json::Value { - // Accept any object. - json!({ "type": "object" }) - } - async fn execute(&self, args: serde_json::Value) -> owlen_core::Result { - Ok(ToolResult::success(args)) - } -} - -#[tokio::test] -async fn test_tool_allowed_in_chat_mode() { - // Build a config where the `echo` tool is explicitly allowed in chat. - let cfg = Config { - modes: ModeConfig { - chat: ModeToolConfig { - allowed_tools: vec!["echo".to_string()], - }, - code: ModeToolConfig { - allowed_tools: vec!["*".to_string()], - }, - }, - ..Default::default() - }; - let cfg = Arc::new(Mutex::new(cfg)); - - let ui: Arc = Arc::new(NoOpUiController); - let mut reg = ToolRegistry::new(cfg.clone(), ui); - reg.register(EchoTool).unwrap(); - - let args = json!({ "msg": "hello" }); - let result = reg - .execute("echo", args.clone(), Mode::Chat) - .await - .expect("execution should succeed"); - - assert!(result.success, "Tool should succeed when allowed"); - assert_eq!(result.output, args, "Output should echo the input"); -} - -#[tokio::test] -async fn test_tool_not_allowed_in_any_mode() { - // Config that does NOT list `echo` in either mode. - let cfg = Config { - modes: ModeConfig { - chat: ModeToolConfig { - allowed_tools: vec![WEB_SEARCH_TOOL_NAME.to_string()], - }, - code: ModeToolConfig { - // Strict denial - only web_search allowed - allowed_tools: vec![WEB_SEARCH_TOOL_NAME.to_string()], - }, - }, - ..Default::default() - }; - let cfg = Arc::new(Mutex::new(cfg)); - - let ui: Arc = Arc::new(NoOpUiController); - let mut reg = ToolRegistry::new(cfg.clone(), ui); - reg.register(EchoTool).unwrap(); - - let args = json!({ "msg": "hello" }); - let result = reg - .execute("echo", args, Mode::Chat) - .await - .expect("execution should return a ToolResult"); - - // Expect an error indicating the tool is unavailable in any mode. - assert!(!result.success, "Tool should be rejected when not allowed"); - let err_msg = result - .output - .get("error") - .and_then(|v| v.as_str()) - .unwrap_or(""); - assert!( - err_msg.contains("not available in any mode"), - "Error message should explain unavailability" - ); -} diff --git a/crates/owlen-core/tests/ollama_wiremock.rs b/crates/owlen-core/tests/ollama_wiremock.rs deleted file mode 100644 index d456385..0000000 --- a/crates/owlen-core/tests/ollama_wiremock.rs +++ /dev/null @@ -1,416 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use owlen_core::tools::WEB_SEARCH_TOOL_NAME; -use owlen_core::types::{ChatParameters, ChatResponse, Role}; -use owlen_core::{ - Config, Provider, - providers::OllamaProvider, - session::{SessionController, SessionOutcome}, - storage::StorageManager, - ui::NoOpUiController, -}; -use serde_json::{Value, json}; -use tempfile::{TempDir, tempdir}; -use wiremock::{ - Match, Mock, MockServer, Request, ResponseTemplate, - matchers::{header, method, path}, -}; - -#[derive(Clone, Copy)] -struct BodySubstringMatcher { - needle: &'static str, - should_contain: bool, -} - -impl BodySubstringMatcher { - const fn contains(needle: &'static str) -> Self { - Self { - needle, - should_contain: true, - } - } - - const fn not_contains(needle: &'static str) -> Self { - Self { - needle, - should_contain: false, - } - } -} - -impl Match for BodySubstringMatcher { - fn matches(&self, request: &Request) -> bool { - let body_str = std::str::from_utf8(&request.body).unwrap_or_default(); - body_str.contains(self.needle) == self.should_contain - } -} - -fn load_fixture(name: &str) -> Value { - match name { - "ollama_tags" => serde_json::from_str(include_str!("fixtures/ollama_tags.json")) - .expect("valid tags fixture"), - "ollama_local_completion" => { - serde_json::from_str(include_str!("fixtures/ollama_local_completion.json")) - .expect("valid local completion fixture") - } - "ollama_cloud_tool_call" => { - serde_json::from_str(include_str!("fixtures/ollama_cloud_tool_call.json")) - .expect("valid cloud tool call fixture") - } - "ollama_cloud_final" => { - serde_json::from_str(include_str!("fixtures/ollama_cloud_final.json")) - .expect("valid cloud final fixture") - } - other => panic!("unknown fixture '{other}'"), - } -} - -async fn create_session( - provider: Arc, - config: Config, -) -> (SessionController, TempDir) { - let temp_dir = tempdir().expect("temp dir"); - let storage_path = temp_dir.path().join("owlen-tests.db"); - let storage = Arc::new( - StorageManager::with_database_path(storage_path) - .await - .expect("storage manager"), - ); - let ui = Arc::new(NoOpUiController); - - let session = SessionController::new(provider, config, storage, ui, false, None) - .await - .expect("session controller"); - (session, temp_dir) -} - -async fn send_prompt(session: &mut SessionController, message: &str) -> ChatResponse { - match session - .send_message(message.to_string(), ChatParameters::default()) - .await - { - Ok(SessionOutcome::Complete(response)) => response, - Ok(SessionOutcome::Streaming { .. }) => { - panic!("expected complete outcome, got streaming response") - } - Err(err) => panic!("send_message failed: {err:?}"), - } -} - -fn configure_local(base_url: &str) -> Config { - let mut config = Config::default(); - config.general.default_provider = "ollama_local".into(); - config.general.default_model = Some("local-mini".into()); - config.general.enable_streaming = false; - config.privacy.encrypt_local_data = false; - config.privacy.require_consent_per_session = false; - - if let Some(local) = config.providers.get_mut("ollama_local") { - local.enabled = true; - local.base_url = Some(base_url.to_string()); - } - - config -} - -fn configure_cloud(base_url: &str, search_endpoint: Option<&str>) -> Config { - let mut config = Config::default(); - config.general.default_provider = "ollama_cloud".into(); - config.general.default_model = Some("llama3:8b-cloud".into()); - config.general.enable_streaming = false; - config.privacy.enable_remote_search = true; - config.privacy.encrypt_local_data = false; - config.privacy.require_consent_per_session = false; - config.tools.web_search.enabled = true; - unsafe { - std::env::set_var("OWLEN_ALLOW_INSECURE_CLOUD", "1"); - } - - if let Some(cloud) = config.providers.get_mut("ollama_cloud") { - cloud.enabled = true; - cloud.base_url = Some(base_url.to_string()); - cloud.api_key = Some("test-key".into()); - if let Some(endpoint) = search_endpoint { - cloud - .extra - .insert("web_search_endpoint".into(), Value::String(endpoint.into())); - } - cloud.extra.insert( - owlen_core::config::OLLAMA_CLOUD_ENDPOINT_KEY.into(), - Value::String(base_url.to_string()), - ); - } - - config -} - -async fn run_local_completion(base_suffix: &str) { - let server = MockServer::start().await; - let raw_base = format!("{}{}", server.uri(), base_suffix); - - let tags = load_fixture("ollama_tags"); - let completion = load_fixture("ollama_local_completion"); - - Mock::given(method("GET")) - .and(path("/api/tags")) - .respond_with(ResponseTemplate::new(200).set_body_json(tags)) - .mount(&server) - .await; - - Mock::given(method("POST")) - .and(path("/api/chat")) - .respond_with(ResponseTemplate::new(200).set_body_json(completion)) - .expect(1) - .mount(&server) - .await; - - let config = configure_local(&raw_base); - let provider: Arc = - Arc::new(OllamaProvider::new(raw_base.clone()).expect("local provider")); - - let (mut session, _tmp) = create_session(provider, config).await; - let response = send_prompt(&mut session, "Summarise the local status.").await; - assert_eq!(response.message.content, "Local response complete."); - - let snapshot = session - .current_usage_snapshot() - .await - .expect("usage snapshot"); - assert_eq!(snapshot.provider, "ollama_local"); - assert_eq!(snapshot.hourly.total_tokens, 36); - assert_eq!(snapshot.weekly.total_tokens, 36); -} - -async fn run_cloud_tool_flow( - base_suffix: &str, - search_endpoint: Option<&str>, - expected_search_path: &str, -) { - let server = MockServer::start().await; - let raw_base = format!("{}{}", server.uri(), base_suffix); - - let tags = load_fixture("ollama_tags"); - let tool_call = load_fixture("ollama_cloud_tool_call"); - let final_chunk = load_fixture("ollama_cloud_final"); - - Mock::given(method("GET")) - .and(path("/api/tags")) - .and(header("authorization", "Bearer test-key")) - .respond_with(ResponseTemplate::new(200).set_body_json(tags)) - .mount(&server) - .await; - - Mock::given(method("POST")) - .and(path("/api/chat")) - .and(header("authorization", "Bearer test-key")) - .and(BodySubstringMatcher::not_contains("\"role\":\"tool\"")) - .respond_with(ResponseTemplate::new(200).set_body_json(tool_call)) - .expect(1) - .mount(&server) - .await; - - Mock::given(method("POST")) - .and(path("/api/chat")) - .and(header("authorization", "Bearer test-key")) - .and(BodySubstringMatcher::contains("\"role\":\"tool\"")) - .respond_with(ResponseTemplate::new(200).set_body_json(final_chunk)) - .expect(1) - .mount(&server) - .await; - - Mock::given(method("POST")) - .and(path(expected_search_path)) - .and(header("authorization", "Bearer test-key")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "results": [ - { - "title": "Rust 1.85 Released", - "url": "https://blog.rust-lang.org/2025/10/23/Rust-1.85.html", - "snippet": "Rust 1.85 lands with incremental compilation improvements." - } - ] - }))) - .expect(1) - .mount(&server) - .await; - - let config = configure_cloud(&raw_base, search_endpoint); - let cloud_cfg = config - .providers - .get("ollama_cloud") - .expect("cloud provider config") - .clone(); - assert_eq!(cloud_cfg.api_key.as_deref(), Some("test-key")); - assert_eq!(cloud_cfg.base_url.as_deref(), Some(raw_base.as_str())); - assert_eq!(cloud_cfg.api_key.as_deref(), Some("test-key")); - assert_eq!(cloud_cfg.base_url.as_deref(), Some(raw_base.as_str())); - - let provider: Arc = Arc::new( - OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general)) - .expect("cloud provider"), - ); - - let (mut session, _tmp) = create_session(provider, config).await; - let search_url = format!( - "{}/{}", - raw_base.trim_end_matches('/'), - expected_search_path.trim_start_matches('/') - ); - - session.grant_consent( - WEB_SEARCH_TOOL_NAME, - vec!["network".into()], - vec![search_url], - ); - - let response = send_prompt(&mut session, "What is new in Rust today?").await; - assert_eq!( - response.message.content, - "Rust 1.85 shipped today. Summarising the highlights now." - ); - - let convo = session.conversation(); - let tool_messages: Vec<_> = convo - .messages - .iter() - .filter(|msg| msg.role == Role::Tool) - .collect(); - assert_eq!(tool_messages.len(), 1); - assert!( - tool_messages[0].content.contains("Rust 1.85 Released"), - "tool response should include search result" - ); - - let snapshot = session - .current_usage_snapshot() - .await - .expect("usage snapshot"); - assert_eq!(snapshot.provider, "ollama_cloud"); - assert_eq!(snapshot.hourly.total_tokens, 112); - assert_eq!(snapshot.weekly.total_tokens, 112); -} - -async fn run_cloud_error( - base_suffix: &str, - status: u16, - error_body: Value, - prompt: &str, -) -> String { - let server = MockServer::start().await; - let raw_base = format!("{}{}", server.uri(), base_suffix); - let search_endpoint = if base_suffix.is_empty() { - "/v1/web/search" - } else { - "/web/search" - }; - - let tags = load_fixture("ollama_tags"); - - Mock::given(method("GET")) - .and(path("/api/tags")) - .and(header("authorization", "Bearer test-key")) - .respond_with(ResponseTemplate::new(200).set_body_json(tags)) - .mount(&server) - .await; - - Mock::given(method("POST")) - .and(path("/api/chat")) - .and(header("authorization", "Bearer test-key")) - .respond_with( - ResponseTemplate::new(status) - .set_body_json(error_body) - .set_delay(Duration::from_millis(5)), - ) - .expect(1) - .mount(&server) - .await; - - let config = configure_cloud(&raw_base, Some(search_endpoint)); - let cloud_cfg = config - .providers - .get("ollama_cloud") - .expect("cloud provider config") - .clone(); - let provider: Arc = Arc::new( - OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general)) - .expect("cloud provider"), - ); - - let (mut session, _tmp) = create_session(provider, config).await; - - let err_text = match session - .send_message(prompt.to_string(), ChatParameters::default()) - .await - { - Ok(_) => panic!("expected error status {status} but request succeeded"), - Err(err) => err.to_string(), - }; - - let snapshot = session - .current_usage_snapshot() - .await - .expect("usage snapshot"); - assert_eq!(snapshot.hourly.total_tokens, 0); - assert_eq!(snapshot.weekly.total_tokens, 0); - - err_text -} - -#[tokio::test(flavor = "multi_thread")] -async fn local_provider_happy_path_records_usage() { - run_local_completion("").await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn local_provider_accepts_v1_base_url() { - run_local_completion("/v1").await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_tool_call_flows_through_web_search() { - run_cloud_tool_flow("", Some("/v1/web/search"), "/v1/web/search").await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_tool_call_accepts_v1_base_url() { - run_cloud_tool_flow("/v1", Some("/web/search"), "/web/search").await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_tool_call_uses_default_search_endpoint() { - run_cloud_tool_flow("", None, "/api/web_search").await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_unauthorized_degrades_without_usage() { - for suffix in ["", "/v1"] { - let err_text = run_cloud_error( - suffix, - 401, - json!({ "error": "unauthorized" }), - "Switch to cloud", - ) - .await; - assert!( - err_text.contains("unauthorized") || err_text.contains("API key"), - "error should surface unauthorized detail for base '{suffix}', got: {err_text}" - ); - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_rate_limit_returns_error_without_usage() { - for suffix in ["", "/v1"] { - let err_text = run_cloud_error( - suffix, - 429, - json!({ "error": "too many requests" }), - "Hit rate limit", - ) - .await; - assert!( - err_text.contains("rate limited") || err_text.contains("429"), - "error should mention rate limiting for base '{suffix}', got: {err_text}" - ); - } -} diff --git a/crates/owlen-core/tests/phase9_remoting.rs b/crates/owlen-core/tests/phase9_remoting.rs deleted file mode 100644 index 0a4068b..0000000 --- a/crates/owlen-core/tests/phase9_remoting.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! Integration tests for Phase 9: Remoting / Cloud Hybrid Deployment -//! -//! Tests WebSocket transport, failover mechanisms, and health checking. - -use owlen_core::mcp::failover::{FailoverConfig, FailoverMcpClient, ServerEntry, ServerHealth}; -use owlen_core::mcp::{McpClient, McpToolCall, McpToolDescriptor}; -use owlen_core::{Error, Result}; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; - -/// Mock MCP client for testing failover behavior -struct MockMcpClient { - name: String, - fail_count: AtomicUsize, - max_failures: usize, -} - -impl MockMcpClient { - fn new(name: &str, max_failures: usize) -> Self { - Self { - name: name.to_string(), - fail_count: AtomicUsize::new(0), - max_failures, - } - } - - fn always_healthy(name: &str) -> Self { - Self::new(name, 0) - } - - fn fail_n_times(name: &str, n: usize) -> Self { - Self::new(name, n) - } -} - -#[async_trait::async_trait] -impl McpClient for MockMcpClient { - async fn list_tools(&self) -> Result> { - let current = self.fail_count.fetch_add(1, Ordering::SeqCst); - if current < self.max_failures { - Err(Error::Network(format!( - "Mock failure {} from '{}'", - current + 1, - self.name - ))) - } else { - Ok(vec![McpToolDescriptor { - name: format!("test_tool_{}", self.name), - description: format!("Tool from {}", self.name), - input_schema: serde_json::json!({}), - requires_network: false, - requires_filesystem: vec![], - }]) - } - } - - async fn call_tool(&self, call: McpToolCall) -> Result { - let current = self.fail_count.load(Ordering::SeqCst); - if current < self.max_failures { - Err(Error::Network(format!("Mock failure from '{}'", self.name))) - } else { - Ok(owlen_core::mcp::McpToolResponse { - name: call.name, - success: true, - output: serde_json::json!({ "server": self.name }), - metadata: std::collections::HashMap::new(), - duration_ms: 0, - }) - } - } -} - -#[tokio::test] -async fn test_failover_basic_priority() { - // Create two healthy servers with different priorities - let primary = Arc::new(MockMcpClient::always_healthy("primary")); - let backup = Arc::new(MockMcpClient::always_healthy("backup")); - - let servers = vec![ - ServerEntry::new("primary".to_string(), primary as Arc, 1), - ServerEntry::new("backup".to_string(), backup as Arc, 2), - ]; - - let client = FailoverMcpClient::with_servers(servers); - - // Should use primary (lower priority number) - let tools = client.list_tools().await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].name, "test_tool_primary"); -} - -#[tokio::test] -async fn test_failover_with_retry() { - // Primary fails 2 times, then succeeds - let primary = Arc::new(MockMcpClient::fail_n_times("primary", 2)); - let backup = Arc::new(MockMcpClient::always_healthy("backup")); - - let servers = vec![ - ServerEntry::new("primary".to_string(), primary as Arc, 1), - ServerEntry::new("backup".to_string(), backup as Arc, 2), - ]; - - let config = FailoverConfig { - max_retries: 3, - base_retry_delay: Duration::from_millis(10), - health_check_interval: Duration::from_secs(30), - health_check_timeout: Duration::from_secs(5), - circuit_breaker_threshold: 5, - }; - - let client = FailoverMcpClient::new(servers, config); - - // Should eventually succeed after retries - let tools = client.list_tools().await.unwrap(); - assert_eq!(tools.len(), 1); - // After 2 failures and 1 success, should get the tool - assert!(tools[0].name.contains("test_tool")); -} - -#[tokio::test] -async fn test_failover_to_backup() { - // Primary always fails, backup always succeeds - let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999)); - let backup = Arc::new(MockMcpClient::always_healthy("backup")); - - let servers = vec![ - ServerEntry::new("primary".to_string(), primary as Arc, 1), - ServerEntry::new("backup".to_string(), backup as Arc, 2), - ]; - - let config = FailoverConfig { - max_retries: 5, - base_retry_delay: Duration::from_millis(5), - health_check_interval: Duration::from_secs(30), - health_check_timeout: Duration::from_secs(5), - circuit_breaker_threshold: 3, - }; - - let client = FailoverMcpClient::new(servers, config); - - // Should failover to backup after exhausting retries on primary - let tools = client.list_tools().await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].name, "test_tool_backup"); -} - -#[tokio::test] -async fn test_server_health_tracking() { - let client = Arc::new(MockMcpClient::always_healthy("test")); - let entry = ServerEntry::new("test".to_string(), client, 1); - - // Initial state should be healthy - assert!(entry.is_available().await); - assert_eq!(entry.get_health().await, ServerHealth::Healthy); - - // Mark as degraded - entry.mark_degraded().await; - assert!(!entry.is_available().await); - match entry.get_health().await { - ServerHealth::Degraded { .. } => {} - _ => panic!("Expected Degraded state"), - } - - // Mark as down - entry.mark_down().await; - assert!(!entry.is_available().await); - match entry.get_health().await { - ServerHealth::Down { .. } => {} - _ => panic!("Expected Down state"), - } - - // Recover to healthy - entry.mark_healthy().await; - assert!(entry.is_available().await); - assert_eq!(entry.get_health().await, ServerHealth::Healthy); -} - -#[tokio::test] -async fn test_health_check_all() { - let healthy = Arc::new(MockMcpClient::always_healthy("healthy")); - let unhealthy = Arc::new(MockMcpClient::fail_n_times("unhealthy", 999)); - - let servers = vec![ - ServerEntry::new("healthy".to_string(), healthy as Arc, 1), - ServerEntry::new("unhealthy".to_string(), unhealthy as Arc, 2), - ]; - - let client = FailoverMcpClient::with_servers(servers); - - // Run health check - client.health_check_all().await; - - // Give spawned tasks time to complete - tokio::time::sleep(Duration::from_millis(100)).await; - - // Check server status - let status = client.get_server_status().await; - assert_eq!(status.len(), 2); - - // Healthy server should be healthy - let healthy_status = status.iter().find(|(name, _)| name == "healthy").unwrap(); - assert_eq!(healthy_status.1, ServerHealth::Healthy); - - // Unhealthy server should be down - let unhealthy_status = status.iter().find(|(name, _)| name == "unhealthy").unwrap(); - match unhealthy_status.1 { - ServerHealth::Down { .. } => {} - _ => panic!("Expected unhealthy server to be Down"), - } -} - -#[tokio::test] -async fn test_call_tool_failover() { - // Primary fails, backup succeeds - let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999)); - let backup = Arc::new(MockMcpClient::always_healthy("backup")); - - let servers = vec![ - ServerEntry::new("primary".to_string(), primary as Arc, 1), - ServerEntry::new("backup".to_string(), backup as Arc, 2), - ]; - - let config = FailoverConfig { - max_retries: 5, - base_retry_delay: Duration::from_millis(5), - ..Default::default() - }; - - let client = FailoverMcpClient::new(servers, config); - - // Call a tool - should failover to backup - let call = McpToolCall { - name: "test_tool".to_string(), - arguments: serde_json::json!({}), - }; - - let response = client.call_tool(call).await.unwrap(); - assert!(response.success); - assert_eq!(response.output["server"], "backup"); -} - -#[tokio::test] -async fn test_exponential_backoff() { - // Test that retry delays increase exponentially - let client = Arc::new(MockMcpClient::fail_n_times("test", 2)); - let entry = ServerEntry::new("test".to_string(), client, 1); - - let config = FailoverConfig { - max_retries: 3, - base_retry_delay: Duration::from_millis(10), - ..Default::default() - }; - - let failover = FailoverMcpClient::new(vec![entry], config); - - let start = std::time::Instant::now(); - let _ = failover.list_tools().await; - let elapsed = start.elapsed(); - - // With base delay of 10ms and 2 retries: - // Attempt 1: immediate - // Attempt 2: 10ms delay (2^0 * 10) - // Attempt 3: 20ms delay (2^1 * 10) - // Total should be at least 30ms - assert!( - elapsed >= Duration::from_millis(30), - "Expected at least 30ms, got {:?}", - elapsed - ); -} - -#[tokio::test] -async fn test_no_servers_configured() { - let config = FailoverConfig::default(); - let client = FailoverMcpClient::new(vec![], config); - - let result = client.list_tools().await; - assert!(result.is_err()); - match result { - Err(Error::Network(msg)) => assert!(msg.contains("No servers configured")), - _ => panic!("Expected Network error"), - } -} - -#[tokio::test] -async fn test_all_servers_fail() { - // Both servers always fail - let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999)); - let backup = Arc::new(MockMcpClient::fail_n_times("backup", 999)); - - let servers = vec![ - ServerEntry::new("primary".to_string(), primary as Arc, 1), - ServerEntry::new("backup".to_string(), backup as Arc, 2), - ]; - - let config = FailoverConfig { - max_retries: 2, - base_retry_delay: Duration::from_millis(5), - ..Default::default() - }; - - let client = FailoverMcpClient::new(servers, config); - - let result = client.list_tools().await; - assert!(result.is_err()); - match result { - Err(Error::Network(_)) => {} // Expected - _ => panic!("Expected Network error"), - } -} diff --git a/crates/owlen-core/tests/presets.rs b/crates/owlen-core/tests/presets.rs deleted file mode 100644 index b24bb60..0000000 --- a/crates/owlen-core/tests/presets.rs +++ /dev/null @@ -1,62 +0,0 @@ -use owlen_core::config::Config; -use owlen_core::mcp::presets::{PresetTier, apply_preset, audit_preset}; - -#[test] -fn standard_preset_produces_spec_compliant_servers() { - let configs = Config::preset_servers(PresetTier::Standard); - assert!( - !configs.is_empty(), - "expected standard preset to contain connectors" - ); - for server in configs { - assert!( - server - .name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), - "server '{}' failed identifier validation", - server.name - ); - } -} - -#[test] -fn apply_preset_adds_missing_servers() { - let mut config = Config::default(); - let report = apply_preset(&mut config, PresetTier::Standard, false).expect("apply preset"); - assert!(!report.added.is_empty(), "expected connectors to be added"); - assert!(report.updated.is_empty()); - assert!(report.removed.is_empty()); - - let audit = audit_preset(&config, PresetTier::Standard); - assert!( - audit.missing.is_empty(), - "expected no missing connectors after install" - ); -} - -#[test] -fn prune_preset_removes_extra_entries() { - let mut config = Config::default(); - // Seed with an extra entry - config - .mcp_servers - .push(owlen_core::config::McpServerConfig { - name: "custom_tool".into(), - command: "custom-cmd".into(), - args: vec![], - transport: "stdio".into(), - env: Default::default(), - oauth: None, - rpc_timeout_secs: None, - }); - - let report = apply_preset(&mut config, PresetTier::Standard, true).expect("apply preset"); - assert!(report.removed.contains(&"custom_tool".to_string())); - assert!( - config - .mcp_servers - .iter() - .all(|srv| srv.name != "custom_tool") - ); -} diff --git a/crates/owlen-core/tests/prompt_server.rs b/crates/owlen-core/tests/prompt_server.rs deleted file mode 100644 index 635bc64..0000000 --- a/crates/owlen-core/tests/prompt_server.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Integration test for the MCP prompt rendering server. - -use owlen_core::Result; -use owlen_core::config::McpServerConfig; -use owlen_core::mcp::client::RemoteMcpClient; -use owlen_core::mcp::{McpToolCall, McpToolResponse}; -use serde_json::json; -use std::path::PathBuf; - -#[tokio::test] -async fn test_render_prompt_via_external_server() -> Result<()> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .and_then(|p| p.parent()) - .expect("workspace root"); - - let candidates = [ - workspace_root - .join("target") - .join("debug") - .join("owlen-mcp-prompt-server"), - workspace_root - .join("owlen-mcp-prompt-server") - .join("target") - .join("debug") - .join("owlen-mcp-prompt-server"), - ]; - - let binary = if let Some(path) = candidates.iter().find(|path| path.exists()) { - path.clone() - } else { - eprintln!( - "Skipping prompt server integration test: binary not found. \ - Build it with `cargo build -p owlen-mcp-prompt-server`. Tried {:?}", - candidates - ); - return Ok(()); - }; - - let config = McpServerConfig { - name: "prompt_server".into(), - command: binary.to_string_lossy().into_owned(), - args: Vec::new(), - transport: "stdio".into(), - env: std::collections::HashMap::new(), - oauth: None, - rpc_timeout_secs: None, - }; - - let client = match RemoteMcpClient::new_with_config(&config).await { - Ok(client) => client, - Err(err) => { - eprintln!( - "Skipping prompt server integration test: failed to launch {} ({err})", - config.command - ); - return Ok(()); - } - }; - - let call = McpToolCall { - name: "render_prompt".into(), - arguments: json!({ - "template_name": "example", - "variables": {"name": "Alice", "role": "Tester"} - }), - }; - - let resp: McpToolResponse = client.call_tool(call).await?; - assert!(resp.success, "Tool reported failure: {:?}", resp); - let output = resp.output.as_str().unwrap_or(""); - assert!(output.contains("Alice"), "Output missing name: {}", output); - assert!(output.contains("Tester"), "Output missing role: {}", output); - Ok(()) -} diff --git a/crates/owlen-core/tests/provider_manager_edge_cases.rs b/crates/owlen-core/tests/provider_manager_edge_cases.rs deleted file mode 100644 index 5c58b6e..0000000 --- a/crates/owlen-core/tests/provider_manager_edge_cases.rs +++ /dev/null @@ -1,517 +0,0 @@ -//! Comprehensive edge case tests for ProviderManager -//! -//! This test suite covers: -//! 1. Provider health check transitions (Available → Unavailable → Available) -//! 2. Concurrent provider registration during model listing -//! 3. Generate request failure propagation -//! 4. Empty provider registry edge cases -//! 5. Provider registration after initial construction - -use owlen_core::provider::{ - GenerateRequest, GenerateStream, ModelInfo, ModelProvider, ProviderManager, ProviderMetadata, - ProviderStatus, ProviderType, -}; -use owlen_core::{Error, Result}; - -use async_trait::async_trait; -use std::collections::HashMap; -use std::sync::Arc; - -#[derive(Clone)] -struct StaticProvider { - metadata: ProviderMetadata, - models: Vec, - status: ProviderStatus, -} - -impl StaticProvider { - fn new( - id: &str, - name: &str, - provider_type: ProviderType, - status: ProviderStatus, - models: Vec, - ) -> Self { - let metadata = ProviderMetadata::new(id, name, provider_type, false); - let mut models = models; - for model in &mut models { - model.provider = metadata.clone(); - } - Self { - metadata, - models, - status, - } - } -} - -#[async_trait] -impl ModelProvider for StaticProvider { - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - - async fn health_check(&self) -> Result { - Ok(self.status) - } - - async fn list_models(&self) -> Result> { - Ok(self.models.clone()) - } - - async fn generate_stream(&self, _request: GenerateRequest) -> Result { - Err(Error::NotImplemented( - "streaming not implemented in StaticProvider".to_string(), - )) - } -} - -fn model(name: &str) -> ModelInfo { - ModelInfo { - name: name.to_string(), - size_bytes: None, - capabilities: Vec::new(), - description: None, - provider: ProviderMetadata::new("unused", "Unused", ProviderType::Local, false), - metadata: HashMap::new(), - } -} - -#[tokio::test] -async fn handles_provider_health_degradation() { - // Test Available → Unavailable transition updates cache - let manager = ProviderManager::default(); - let provider = StaticProvider::new( - "test_provider", - "Test Provider", - ProviderType::Local, - ProviderStatus::Available, - vec![model("test-model")], - ); - manager.register_provider(Arc::new(provider)).await; - - // Initial health check sets status to Available - let models = manager.list_all_models().await.unwrap(); - assert_eq!(models.len(), 1); - assert_eq!(models[0].provider_status, ProviderStatus::Available); - - // Verify status cache was updated - let status = manager - .provider_status("test_provider") - .await - .expect("provider status"); - assert_eq!(status, ProviderStatus::Available); - - // Now register a provider that becomes unavailable - let failing_provider = StaticProvider::new( - "test_provider", - "Test Provider", - ProviderType::Local, - ProviderStatus::Unavailable, - vec![], - ); - manager.register_provider(Arc::new(failing_provider)).await; - - // Refresh health should update to Unavailable - let health_map = manager.refresh_health().await; - assert_eq!( - health_map.get("test_provider"), - Some(&ProviderStatus::Unavailable) - ); - - // Verify status cache reflects the degradation - let status = manager - .provider_status("test_provider") - .await - .expect("provider status"); - assert_eq!(status, ProviderStatus::Unavailable); -} - -#[tokio::test] -async fn concurrent_registration_is_safe() { - // Spawn multiple tasks calling register_provider - let manager = Arc::new(ProviderManager::default()); - let mut handles = Vec::new(); - - for i in 0..10 { - let manager_clone = Arc::clone(&manager); - let handle = tokio::spawn(async move { - let provider = StaticProvider::new( - &format!("provider_{}", i), - &format!("Provider {}", i), - ProviderType::Local, - ProviderStatus::Available, - vec![model(&format!("model-{}", i))], - ); - manager_clone.register_provider(Arc::new(provider)).await; - }); - handles.push(handle); - } - - // Wait for all registrations to complete - for handle in handles { - handle.await.expect("task should complete successfully"); - } - - // Verify all providers were registered - let provider_ids = manager.provider_ids().await; - assert_eq!(provider_ids.len(), 10); - - // Verify all statuses were initialized - let statuses = manager.provider_statuses().await; - assert_eq!(statuses.len(), 10); - for (_, status) in statuses { - assert_eq!(status, ProviderStatus::Unavailable); // Initial registration status - } -} - -#[tokio::test] -async fn concurrent_model_listing_during_registration() { - // Test that listing models while registering providers is safe - let manager = Arc::new(ProviderManager::default()); - let mut handles = Vec::new(); - - // Spawn tasks that register providers - for i in 0..5 { - let manager_clone = Arc::clone(&manager); - let handle = tokio::spawn(async move { - let provider = StaticProvider::new( - &format!("provider_{}", i), - &format!("Provider {}", i), - ProviderType::Local, - ProviderStatus::Available, - vec![model(&format!("model-{}", i))], - ); - manager_clone.register_provider(Arc::new(provider)).await; - }); - handles.push(handle); - } - - // Spawn tasks that list models concurrently - for _ in 0..5 { - let manager_clone = Arc::clone(&manager); - let handle = tokio::spawn(async move { - let _ = manager_clone.list_all_models().await; - }); - handles.push(handle); - } - - // Wait for all tasks to complete without panicking - for handle in handles { - handle.await.expect("task should complete successfully"); - } - - // Final model list should contain all registered providers - let models = manager.list_all_models().await.unwrap(); - assert_eq!(models.len(), 5); -} - -#[tokio::test] -async fn generate_failure_updates_status() { - // Verify failed generate() marks provider Unavailable - let manager = ProviderManager::default(); - let provider = StaticProvider::new( - "test_provider", - "Test Provider", - ProviderType::Local, - ProviderStatus::Available, - vec![model("test-model")], - ); - manager.register_provider(Arc::new(provider)).await; - - // Initial status should be Unavailable (from registration) - let status = manager - .provider_status("test_provider") - .await - .expect("provider status"); - assert_eq!(status, ProviderStatus::Unavailable); - - // Attempt to generate (which will fail for StaticProvider) - let request = GenerateRequest::new("test-model"); - - let result = manager.generate("test_provider", request).await; - assert!(result.is_err()); - - // Status should remain Unavailable after failed generation - let status = manager - .provider_status("test_provider") - .await - .expect("provider status"); - assert_eq!(status, ProviderStatus::Unavailable); -} - -#[tokio::test] -async fn generate_with_nonexistent_provider_returns_error() { - let manager = ProviderManager::default(); - - let request = GenerateRequest::new("some-model"); - - let result = manager.generate("nonexistent_provider", request).await; - assert!(result.is_err()); - match result { - Err(Error::Config(msg)) => { - assert!(msg.contains("nonexistent_provider")); - assert!(msg.contains("not registered")); - } - _ => panic!("expected Config error"), - } -} - -#[tokio::test] -async fn empty_provider_registry_returns_empty_models() { - // Test listing models when no providers are registered - let manager = ProviderManager::default(); - - let models = manager.list_all_models().await.unwrap(); - assert_eq!(models.len(), 0); - - let provider_ids = manager.provider_ids().await; - assert_eq!(provider_ids.len(), 0); - - let statuses = manager.provider_statuses().await; - assert_eq!(statuses.len(), 0); -} - -#[tokio::test] -async fn provider_registration_after_initial_construction() { - // Test that providers can be registered after manager creation - let manager = ProviderManager::default(); - - // Initially empty - assert_eq!(manager.provider_ids().await.len(), 0); - - // Register first provider - let provider1 = StaticProvider::new( - "provider_1", - "Provider 1", - ProviderType::Local, - ProviderStatus::Available, - vec![model("model-1")], - ); - manager.register_provider(Arc::new(provider1)).await; - - assert_eq!(manager.provider_ids().await.len(), 1); - - // Register second provider - let provider2 = StaticProvider::new( - "provider_2", - "Provider 2", - ProviderType::Cloud, - ProviderStatus::Available, - vec![model("model-2")], - ); - manager.register_provider(Arc::new(provider2)).await; - - assert_eq!(manager.provider_ids().await.len(), 2); - - // Both providers should be accessible - let models = manager.list_all_models().await.unwrap(); - assert_eq!(models.len(), 2); -} - -#[tokio::test] -async fn refresh_health_handles_mixed_provider_states() { - // Test refresh_health with providers in different states - let manager = ProviderManager::default(); - - let available_provider = StaticProvider::new( - "available", - "Available Provider", - ProviderType::Local, - ProviderStatus::Available, - vec![model("model-1")], - ); - let unavailable_provider = StaticProvider::new( - "unavailable", - "Unavailable Provider", - ProviderType::Local, - ProviderStatus::Unavailable, - vec![], - ); - let requires_setup = StaticProvider::new( - "requires_setup", - "Setup Provider", - ProviderType::Cloud, - ProviderStatus::RequiresSetup, - vec![], - ); - - manager - .register_provider(Arc::new(available_provider)) - .await; - manager - .register_provider(Arc::new(unavailable_provider)) - .await; - manager.register_provider(Arc::new(requires_setup)).await; - - // Refresh health - let health_map = manager.refresh_health().await; - - assert_eq!(health_map.len(), 3); - assert_eq!( - health_map.get("available"), - Some(&ProviderStatus::Available) - ); - assert_eq!( - health_map.get("unavailable"), - Some(&ProviderStatus::Unavailable) - ); - assert_eq!( - health_map.get("requires_setup"), - Some(&ProviderStatus::RequiresSetup) - ); -} - -#[tokio::test] -async fn list_models_ignores_unavailable_providers() { - // Verify that unavailable providers return no models - let manager = ProviderManager::default(); - - let available = StaticProvider::new( - "available", - "Available Provider", - ProviderType::Local, - ProviderStatus::Available, - vec![model("available-model")], - ); - let unavailable = StaticProvider::new( - "unavailable", - "Unavailable Provider", - ProviderType::Local, - ProviderStatus::Unavailable, - vec![model("unavailable-model")], // Has models but is unavailable - ); - - manager.register_provider(Arc::new(available)).await; - manager.register_provider(Arc::new(unavailable)).await; - - let models = manager.list_all_models().await.unwrap(); - - // Only the available provider's model should be returned - assert_eq!(models.len(), 1); - assert_eq!(models[0].model.name, "available-model"); - assert_eq!(models[0].provider_id, "available"); -} - -// Test for provider that fails health check but later recovers -#[derive(Clone)] -struct FlakeyProvider { - metadata: ProviderMetadata, - models: Vec, - failure_count: Arc>, - fail_first_n: usize, -} - -impl FlakeyProvider { - fn new(id: &str, fail_first_n: usize) -> Self { - let metadata = ProviderMetadata::new(id, "Flakey Provider", ProviderType::Local, false); - Self { - metadata, - models: vec![model("flakey-model")], - failure_count: Arc::new(tokio::sync::Mutex::new(0)), - fail_first_n, - } - } -} - -#[async_trait] -impl ModelProvider for FlakeyProvider { - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - - async fn health_check(&self) -> Result { - let mut count = self.failure_count.lock().await; - *count += 1; - if *count <= self.fail_first_n { - Ok(ProviderStatus::Unavailable) - } else { - Ok(ProviderStatus::Available) - } - } - - async fn list_models(&self) -> Result> { - Ok(self.models.clone()) - } - - async fn generate_stream(&self, _request: GenerateRequest) -> Result { - Err(Error::NotImplemented("not implemented".to_string())) - } -} - -#[tokio::test] -async fn handles_provider_recovery_after_failure() { - // Test that a provider can transition from Unavailable to Available - // Use force_refresh_health() to bypass the cache for testing - let manager = ProviderManager::default(); - let provider = FlakeyProvider::new("flakey", 2); - manager.register_provider(Arc::new(provider)).await; - - // First health check should be Unavailable - let health1 = manager.force_refresh_health().await; - assert_eq!(health1.get("flakey"), Some(&ProviderStatus::Unavailable)); - - // Second health check should still be Unavailable - let health2 = manager.force_refresh_health().await; - assert_eq!(health2.get("flakey"), Some(&ProviderStatus::Unavailable)); - - // Third health check should be Available - let health3 = manager.force_refresh_health().await; - assert_eq!(health3.get("flakey"), Some(&ProviderStatus::Available)); - - // Fourth health check should remain Available - let health4 = manager.force_refresh_health().await; - assert_eq!(health4.get("flakey"), Some(&ProviderStatus::Available)); -} - -#[tokio::test] -async fn health_check_cache_reduces_actual_checks() { - // Test that health check cache prevents unnecessary provider calls - let manager = ProviderManager::default(); - let provider = FlakeyProvider::new("cached", 0); - manager.register_provider(Arc::new(provider)).await; - - // First call performs actual health check (Available after 1 call) - let health1 = manager.refresh_health().await; - assert_eq!(health1.get("cached"), Some(&ProviderStatus::Available)); - - // Second immediate call should return cached result without calling provider - // If cache wasn't working, FlakeyProvider would still return Unavailable - let health2 = manager.refresh_health().await; - assert_eq!(health2.get("cached"), Some(&ProviderStatus::Available)); - - // Third immediate call should also return cached result - let health3 = manager.refresh_health().await; - assert_eq!(health3.get("cached"), Some(&ProviderStatus::Available)); -} - -#[tokio::test] -async fn get_provider_returns_none_for_nonexistent() { - let manager = ProviderManager::default(); - let provider = manager.get_provider("nonexistent").await; - assert!(provider.is_none()); -} - -#[tokio::test] -async fn get_provider_returns_registered_provider() { - let manager = ProviderManager::default(); - let provider = StaticProvider::new( - "test", - "Test", - ProviderType::Local, - ProviderStatus::Available, - vec![], - ); - manager.register_provider(Arc::new(provider)).await; - - let retrieved = manager.get_provider("test").await; - assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().metadata().id, "test"); -} - -#[tokio::test] -async fn provider_status_returns_none_for_unregistered() { - let manager = ProviderManager::default(); - let status = manager.provider_status("unregistered").await; - assert!(status.is_none()); -} diff --git a/crates/owlen-core/tests/web_search_toggle.rs b/crates/owlen-core/tests/web_search_toggle.rs deleted file mode 100644 index 5a80355..0000000 --- a/crates/owlen-core/tests/web_search_toggle.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::{any::Any, collections::HashMap, sync::Arc}; - -use async_trait::async_trait; -use futures::stream; -use owlen_core::tools::{WEB_SEARCH_TOOL_NAME, tool_name_matches}; -use owlen_core::{ - ChatStream, Provider, Result, - config::Config, - llm::ProviderConfig, - session::SessionController, - storage::StorageManager, - types::{ChatRequest, ChatResponse, Message, ModelInfo}, - ui::NoOpUiController, -}; -use serde_json::Value; -use tempfile::tempdir; - -struct StubCloudProvider; - -#[async_trait] -impl Provider for StubCloudProvider { - fn name(&self) -> &str { - "ollama_cloud" - } - - async fn list_models(&self) -> Result> { - Ok(vec![ModelInfo { - id: "stub-cloud-model".to_string(), - name: "Stub Cloud Model".to_string(), - description: Some("Stub model for web toggle tests".to_string()), - provider: self.name().to_string(), - context_window: Some(8192), - capabilities: vec!["chat".to_string(), "tools".to_string()], - supports_tools: true, - }]) - } - - async fn send_prompt(&self, _request: ChatRequest) -> Result { - Ok(ChatResponse { - message: Message::assistant(String::new()), - usage: None, - is_streaming: false, - is_final: true, - }) - } - - async fn stream_prompt(&self, _request: ChatRequest) -> Result { - Ok(Box::pin(stream::empty())) - } - - async fn health_check(&self) -> Result<()> { - Ok(()) - } - - fn as_any(&self) -> &(dyn Any + Send + Sync) { - self - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn toggling_web_search_updates_config_and_registry() { - let temp_dir = tempdir().expect("temp dir"); - let storage = Arc::new( - StorageManager::with_database_path(temp_dir.path().join("owlen-tests.db")) - .await - .expect("storage"), - ); - - let mut config = Config::default(); - config.privacy.encrypt_local_data = false; - config.general.default_model = Some("stub-cloud-model".into()); - config.general.default_provider = "ollama_cloud".into(); - - let mut provider_cfg = ProviderConfig { - enabled: true, - provider_type: "ollama_cloud".to_string(), - base_url: Some("https://ollama.com".to_string()), - api_key: Some("test-key".to_string()), - api_key_env: None, - extra: HashMap::new(), - }; - provider_cfg.extra.insert( - "web_search_endpoint".into(), - Value::String("/api/web_search".into()), - ); - config.providers.insert("ollama_cloud".into(), provider_cfg); - - let provider: Arc = Arc::new(StubCloudProvider); - let ui = Arc::new(NoOpUiController); - - let mut session = SessionController::new(provider, config, storage, ui, false, None) - .await - .expect("session controller"); - - assert!( - !session - .tool_registry() - .tools() - .iter() - .any(|tool| tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)), - "web_search should be disabled by default" - ); - - session - .set_tool_enabled(WEB_SEARCH_TOOL_NAME, true) - .await - .expect("enable web_search"); - - { - let cfg = session.config_async().await; - assert!(cfg.tools.web_search.enabled); - assert!(cfg.privacy.enable_remote_search); - } - assert!( - session - .tool_registry() - .tools() - .iter() - .any(|tool| tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)), - "web_search should be registered when enabled" - ); - - session - .set_tool_enabled(WEB_SEARCH_TOOL_NAME, false) - .await - .expect("disable web_search"); - - { - let cfg = session.config_async().await; - assert!(!cfg.tools.web_search.enabled); - assert!(!cfg.privacy.enable_remote_search); - } - assert!( - !session - .tool_registry() - .tools() - .iter() - .any(|tool| tool_name_matches(tool, WEB_SEARCH_TOOL_NAME)), - "web_search should be removed when disabled" - ); -} diff --git a/crates/owlen-core/tests/wrap_cursor_tests.rs b/crates/owlen-core/tests/wrap_cursor_tests.rs deleted file mode 100644 index 6fd7f13..0000000 --- a/crates/owlen-core/tests/wrap_cursor_tests.rs +++ /dev/null @@ -1,96 +0,0 @@ -#![allow(non_snake_case)] - -use owlen_core::wrap_cursor::{ScreenPos, build_cursor_map}; - -fn assert_cursor_pos(map: &[ScreenPos], byte_idx: usize, expected: ScreenPos) { - assert_eq!(map[byte_idx], expected, "Mismatch at byte {}", byte_idx); -} - -#[test] -fn test_basic_wrap_at_spaces() { - let text = "hello world"; - let width = 5; - let map = build_cursor_map(text, width); - - assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); - assert_cursor_pos(&map, 5, ScreenPos { row: 0, col: 5 }); // after "hello" - assert_cursor_pos(&map, 6, ScreenPos { row: 1, col: 1 }); // after "hello " - assert_cursor_pos(&map, 11, ScreenPos { row: 1, col: 6 }); // after "world" -} - -#[test] -fn test_hard_line_break() { - let text = "a\nb"; - let width = 10; - let map = build_cursor_map(text, width); - - assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); - assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "a" - assert_cursor_pos(&map, 2, ScreenPos { row: 1, col: 0 }); // after "\n" - assert_cursor_pos(&map, 3, ScreenPos { row: 1, col: 1 }); // after "b" -} - -#[test] -fn test_long_word_split() { - let text = "abcdefgh"; - let width = 3; - let map = build_cursor_map(text, width); - - assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); - assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); - assert_cursor_pos(&map, 2, ScreenPos { row: 0, col: 2 }); - assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 3 }); - assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 1 }); - assert_cursor_pos(&map, 5, ScreenPos { row: 1, col: 2 }); - assert_cursor_pos(&map, 6, ScreenPos { row: 1, col: 3 }); - assert_cursor_pos(&map, 7, ScreenPos { row: 2, col: 1 }); - assert_cursor_pos(&map, 8, ScreenPos { row: 2, col: 2 }); -} - -#[test] -fn test_trailing_spaces_preserved() { - let text = "x y"; - let width = 2; - let map = build_cursor_map(text, width); - - assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); - assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "x" - assert_cursor_pos(&map, 2, ScreenPos { row: 0, col: 2 }); // after "x " - assert_cursor_pos(&map, 3, ScreenPos { row: 1, col: 1 }); // after "x " - assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 2 }); // after "y" -} - -#[test] -fn test_graphemes_emoji() { - let text = "🙂🙂a"; - let width = 3; - let map = build_cursor_map(text, width); - - assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); - assert_cursor_pos(&map, 4, ScreenPos { row: 0, col: 2 }); // after first emoji - assert_cursor_pos(&map, 8, ScreenPos { row: 1, col: 2 }); // after second emoji - assert_cursor_pos(&map, 9, ScreenPos { row: 1, col: 3 }); // after "a" -} - -#[test] -fn test_graphemes_combining() { - let text = "e\u{0301}"; - let width = 10; - let map = build_cursor_map(text, width); - - assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); - assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "e" - assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 1 }); // after combining mark -} - -#[test] -fn test_exact_edge() { - let text = "abc def"; - let width = 3; - let map = build_cursor_map(text, width); - - assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); - assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 3 }); // after "abc" - assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 1 }); // after " " - assert_cursor_pos(&map, 7, ScreenPos { row: 1, col: 4 }); // after "def" -} diff --git a/crates/owlen-markdown/Cargo.toml b/crates/owlen-markdown/Cargo.toml deleted file mode 100644 index f3519f6..0000000 --- a/crates/owlen-markdown/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "owlen-markdown" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Lightweight markdown to ratatui::Text renderer for OWLEN" - -[dependencies] -ratatui = { workspace = true } -unicode-width = "0.2" diff --git a/crates/owlen-markdown/src/lib.rs b/crates/owlen-markdown/src/lib.rs deleted file mode 100644 index e1df1cb..0000000 --- a/crates/owlen-markdown/src/lib.rs +++ /dev/null @@ -1,270 +0,0 @@ -use ratatui::prelude::*; -use ratatui::text::{Line, Span, Text}; -use unicode_width::UnicodeWidthStr; - -/// Convert a markdown string into a `ratatui::Text`. -/// -/// This lightweight renderer supports common constructs (headings, lists, bold, -/// italics, and inline code) and is designed to keep dependencies minimal for -/// the OWLEN project. -pub fn from_str(input: &str) -> Text<'static> { - let mut lines = Vec::new(); - let mut in_code_block = false; - - for raw_line in input.lines() { - let line = raw_line.trim_end_matches('\r'); - let trimmed = line.trim_start(); - let indent = &line[..line.len() - trimmed.len()]; - - if trimmed.starts_with("```") { - in_code_block = !in_code_block; - continue; - } - - if in_code_block { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::raw(indent.to_string())); - } - spans.push(Span::styled( - trimmed.to_string(), - Style::default() - .fg(Color::LightYellow) - .add_modifier(Modifier::DIM), - )); - lines.push(Line::from(spans)); - continue; - } - - if trimmed.is_empty() { - lines.push(Line::from(Vec::>::new())); - continue; - } - - if trimmed.starts_with('#') { - let level = trimmed.chars().take_while(|c| *c == '#').count().min(6); - let content = trimmed[level..].trim_start(); - let mut style = Style::default().add_modifier(Modifier::BOLD); - style = match level { - 1 => style.fg(Color::LightCyan), - 2 => style.fg(Color::Cyan), - _ => style.fg(Color::LightBlue), - }; - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::raw(indent.to_string())); - } - spans.push(Span::styled(content.to_string(), style)); - lines.push(Line::from(spans)); - continue; - } - - if let Some(rest) = trimmed.strip_prefix("- ") { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::raw(indent.to_string())); - } - spans.push(Span::styled( - "• ".to_string(), - Style::default().fg(Color::LightGreen), - )); - spans.extend(parse_inline(rest)); - lines.push(Line::from(spans)); - continue; - } - - if let Some(rest) = trimmed.strip_prefix("* ") { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::raw(indent.to_string())); - } - spans.push(Span::styled( - "• ".to_string(), - Style::default().fg(Color::LightGreen), - )); - spans.extend(parse_inline(rest)); - lines.push(Line::from(spans)); - continue; - } - - if let Some((number, rest)) = parse_ordered_item(trimmed) { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::raw(indent.to_string())); - } - spans.push(Span::styled( - format!("{number}. "), - Style::default().fg(Color::LightGreen), - )); - spans.extend(parse_inline(rest)); - lines.push(Line::from(spans)); - continue; - } - - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::raw(indent.to_string())); - } - spans.extend(parse_inline(trimmed)); - lines.push(Line::from(spans)); - } - - if input.is_empty() { - lines.push(Line::from(Vec::>::new())); - } - - Text::from(lines) -} - -fn parse_ordered_item(line: &str) -> Option<(u32, &str)> { - let mut parts = line.splitn(2, '.'); - let number = parts.next()?.trim(); - let rest = parts.next()?; - if number.chars().all(|c| c.is_ascii_digit()) { - let value = number.parse().ok()?; - let rest = rest.trim_start(); - Some((value, rest)) - } else { - None - } -} - -fn parse_inline(text: &str) -> Vec> { - let mut spans = Vec::new(); - let bytes = text.as_bytes(); - let mut i = 0; - let len = bytes.len(); - let mut plain_start = 0; - - while i < len { - if bytes[i] == b'`' { - if let Some(offset) = text[i + 1..].find('`') { - if i > plain_start { - spans.push(Span::raw(text[plain_start..i].to_string())); - } - let content = &text[i + 1..i + 1 + offset]; - spans.push(Span::styled( - content.to_string(), - Style::default() - .fg(Color::LightYellow) - .add_modifier(Modifier::BOLD), - )); - i += offset + 2; - plain_start = i; - continue; - } else { - break; - } - } - - if bytes[i] == b'*' { - if i + 1 < len && bytes[i + 1] == b'*' { - if let Some(offset) = text[i + 2..].find("**") { - if i > plain_start { - spans.push(Span::raw(text[plain_start..i].to_string())); - } - let content = &text[i + 2..i + 2 + offset]; - spans.push(Span::styled( - content.to_string(), - Style::default().add_modifier(Modifier::BOLD), - )); - i += offset + 4; - plain_start = i; - continue; - } - } else if let Some(offset) = text[i + 1..].find('*') { - if i > plain_start { - spans.push(Span::raw(text[plain_start..i].to_string())); - } - let content = &text[i + 1..i + 1 + offset]; - spans.push(Span::styled( - content.to_string(), - Style::default().add_modifier(Modifier::ITALIC), - )); - i += offset + 2; - plain_start = i; - continue; - } - } - - if bytes[i] == b'_' { - if i + 1 < len && bytes[i + 1] == b'_' { - if let Some(offset) = text[i + 2..].find("__") { - if i > plain_start { - spans.push(Span::raw(text[plain_start..i].to_string())); - } - let content = &text[i + 2..i + 2 + offset]; - spans.push(Span::styled( - content.to_string(), - Style::default().add_modifier(Modifier::BOLD), - )); - i += offset + 4; - plain_start = i; - continue; - } - } else if let Some(offset) = text[i + 1..].find('_') { - if i > plain_start { - spans.push(Span::raw(text[plain_start..i].to_string())); - } - let content = &text[i + 1..i + 1 + offset]; - spans.push(Span::styled( - content.to_string(), - Style::default().add_modifier(Modifier::ITALIC), - )); - i += offset + 2; - plain_start = i; - continue; - } - } - - i += 1; - } - - if plain_start < len { - spans.push(Span::raw(text[plain_start..].to_string())); - } - - if spans.is_empty() { - spans.push(Span::raw(String::new())); - } - - spans -} - -#[allow(dead_code)] -fn visual_length(spans: &[Span<'_>]) -> usize { - spans - .iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn headings_are_bold() { - let text = from_str("# Heading"); - assert_eq!(text.lines.len(), 1); - let line = &text.lines[0]; - assert!( - line.spans - .iter() - .any(|span| span.style.add_modifier.contains(Modifier::BOLD)) - ); - } - - #[test] - fn inline_code_styled() { - let text = from_str("Use `code` inline."); - let styled = text - .lines - .iter() - .flat_map(|line| &line.spans) - .find(|span| span.content.as_ref() == "code") - .cloned() - .unwrap(); - assert!(styled.style.add_modifier.contains(Modifier::BOLD)); - } -} diff --git a/crates/owlen-providers/Cargo.toml b/crates/owlen-providers/Cargo.toml deleted file mode 100644 index 972935b..0000000 --- a/crates/owlen-providers/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "owlen-providers" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Provider implementations for OWLEN" - -[dependencies] -owlen-core = { path = "../owlen-core" } -anyhow = { workspace = true } -async-trait = { workspace = true } -futures = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -tokio-stream = { workspace = true } -reqwest = { package = "reqwest", version = "0.11", features = ["json", "stream"] } -log = { workspace = true } diff --git a/crates/owlen-providers/src/lib.rs b/crates/owlen-providers/src/lib.rs deleted file mode 100644 index 59dd5ac..0000000 --- a/crates/owlen-providers/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Provider implementations for OWLEN. - -pub mod ollama; diff --git a/crates/owlen-providers/src/ollama/cloud.rs b/crates/owlen-providers/src/ollama/cloud.rs deleted file mode 100644 index 4ab5607..0000000 --- a/crates/owlen-providers/src/ollama/cloud.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::{env, time::Duration}; - -use async_trait::async_trait; -use log::warn; -use owlen_core::{ - Error as CoreError, Result as CoreResult, - config::OLLAMA_CLOUD_BASE_URL, - provider::{ - GenerateRequest, GenerateStream, ModelInfo, ModelProvider, ProviderErrorKind, - ProviderMetadata, ProviderStatus, ProviderType, - }, -}; -use serde_json::{Number, Value}; - -use super::OllamaClient; - -const API_KEY_ENV: &str = "OLLAMA_API_KEY"; -const LEGACY_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY"; -const LEGACY_OWLEN_API_KEY_ENV: &str = "OWLEN_OLLAMA_CLOUD_API_KEY"; - -/// ModelProvider implementation for the hosted Ollama Cloud service. -pub struct OllamaCloudProvider { - client: OllamaClient, -} - -impl OllamaCloudProvider { - /// Construct a new cloud provider. An API key must be supplied either - /// directly or via the `OLLAMA_API_KEY` environment variable. - pub fn new( - base_url: Option, - api_key: Option, - request_timeout: Option, - ) -> CoreResult { - let (api_key, key_source) = resolve_api_key(api_key)?; - let base_url = base_url.unwrap_or_else(|| OLLAMA_CLOUD_BASE_URL.to_string()); - - let mut metadata = - ProviderMetadata::new("ollama_cloud", "Ollama (Cloud)", ProviderType::Cloud, true); - metadata - .metadata - .insert("base_url".into(), Value::String(base_url.clone())); - metadata.metadata.insert( - "api_key_source".into(), - Value::String(key_source.to_string()), - ); - metadata - .metadata - .insert("api_key_env".into(), Value::String(API_KEY_ENV.to_string())); - - if let Some(timeout) = request_timeout { - let timeout_ms = timeout.as_millis().min(u128::from(u64::MAX)) as u64; - metadata.metadata.insert( - "request_timeout_ms".into(), - Value::Number(Number::from(timeout_ms)), - ); - } - - let client = OllamaClient::new(&base_url, Some(api_key), metadata, request_timeout)?; - - Ok(Self { client }) - } -} - -#[async_trait] -impl ModelProvider for OllamaCloudProvider { - fn metadata(&self) -> &ProviderMetadata { - self.client.metadata() - } - - async fn health_check(&self) -> CoreResult { - match self.client.health_check().await { - Ok(status) => Ok(status), - Err(CoreError::ProviderFailure(failure)) - if failure.kind == ProviderErrorKind::Unauthorized => - { - Ok(ProviderStatus::RequiresSetup) - } - Err(CoreError::Auth(_)) => Ok(ProviderStatus::RequiresSetup), - Err(err) => Err(err), - } - } - - async fn list_models(&self) -> CoreResult> { - self.client.list_models().await - } - - async fn generate_stream(&self, request: GenerateRequest) -> CoreResult { - self.client.generate_stream(request).await - } -} - -fn resolve_api_key(api_key: Option) -> CoreResult<(String, &'static str)> { - let key_from_config = api_key - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(str::to_string); - - if let Some(key) = key_from_config { - return Ok((key, "config")); - } - - let key_from_env = env::var(API_KEY_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .map(|value| (value, API_KEY_ENV)) - .or_else(|| { - env::var(LEGACY_API_KEY_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .map(|value| (value, LEGACY_API_KEY_ENV)) - }) - .or_else(|| { - env::var(LEGACY_OWLEN_API_KEY_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .map(|value| (value, LEGACY_OWLEN_API_KEY_ENV)) - }); - - if let Some((key, source_env)) = key_from_env { - if source_env != API_KEY_ENV { - warn!( - "Using legacy Ollama Cloud API key environment variable `{source_env}`. Prefer OLLAMA_API_KEY." - ); - } - return Ok((key, "env")); - } - - Err(CoreError::Config( - "Ollama Cloud API key not configured. Set OLLAMA_API_KEY (legacy: OLLAMA_CLOUD_API_KEY / OWLEN_OLLAMA_CLOUD_API_KEY) or configure an API key." - .into(), - )) -} diff --git a/crates/owlen-providers/src/ollama/local.rs b/crates/owlen-providers/src/ollama/local.rs deleted file mode 100644 index b88d4fb..0000000 --- a/crates/owlen-providers/src/ollama/local.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::time::Duration; - -use async_trait::async_trait; -use owlen_core::provider::{ - GenerateRequest, GenerateStream, ModelInfo, ModelProvider, ProviderMetadata, ProviderStatus, - ProviderType, -}; -use owlen_core::{Error as CoreError, Result as CoreResult}; -use serde_json::{Number, Value}; -use tokio::time::timeout; - -use super::OllamaClient; - -const DEFAULT_BASE_URL: &str = "http://localhost:11434"; -const DEFAULT_HEALTH_TIMEOUT_SECS: u64 = 5; - -/// ModelProvider implementation for a local Ollama daemon. -pub struct OllamaLocalProvider { - client: OllamaClient, - health_timeout: Duration, -} - -impl OllamaLocalProvider { - /// Construct a new local provider using the shared [`OllamaClient`]. - pub fn new( - base_url: Option, - request_timeout: Option, - health_timeout: Option, - ) -> CoreResult { - let base_url = base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); - let health_timeout = - health_timeout.unwrap_or_else(|| Duration::from_secs(DEFAULT_HEALTH_TIMEOUT_SECS)); - - let mut metadata = - ProviderMetadata::new("ollama_local", "Ollama (Local)", ProviderType::Local, false); - metadata - .metadata - .insert("base_url".into(), Value::String(base_url.clone())); - if let Some(timeout) = request_timeout { - let timeout_ms = timeout.as_millis().min(u128::from(u64::MAX)) as u64; - metadata.metadata.insert( - "request_timeout_ms".into(), - Value::Number(Number::from(timeout_ms)), - ); - } - - let client = OllamaClient::new(&base_url, None, metadata, request_timeout)?; - - Ok(Self { - client, - health_timeout, - }) - } -} - -#[async_trait] -impl ModelProvider for OllamaLocalProvider { - fn metadata(&self) -> &ProviderMetadata { - self.client.metadata() - } - - async fn health_check(&self) -> CoreResult { - match timeout(self.health_timeout, self.client.health_check()).await { - Ok(Ok(status)) => Ok(status), - Ok(Err(CoreError::Network(_))) | Ok(Err(CoreError::Timeout(_))) => { - Ok(ProviderStatus::Unavailable) - } - Ok(Err(err)) => Err(err), - Err(_) => Ok(ProviderStatus::Unavailable), - } - } - - async fn list_models(&self) -> CoreResult> { - self.client.list_models().await - } - - async fn generate_stream(&self, request: GenerateRequest) -> CoreResult { - self.client.generate_stream(request).await - } -} diff --git a/crates/owlen-providers/src/ollama/mod.rs b/crates/owlen-providers/src/ollama/mod.rs deleted file mode 100644 index 38baa85..0000000 --- a/crates/owlen-providers/src/ollama/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod cloud; -pub mod local; -pub mod shared; - -pub use cloud::OllamaCloudProvider; -pub use local::OllamaLocalProvider; -pub use shared::OllamaClient; diff --git a/crates/owlen-providers/src/ollama/shared.rs b/crates/owlen-providers/src/ollama/shared.rs deleted file mode 100644 index 0c60153..0000000 --- a/crates/owlen-providers/src/ollama/shared.rs +++ /dev/null @@ -1,548 +0,0 @@ -use std::collections::HashMap; -use std::time::Duration; - -use anyhow::anyhow; -use futures::StreamExt; -use log::warn; -use owlen_core::provider::{ - GenerateChunk, GenerateRequest, GenerateStream, ModelInfo, ProviderError, ProviderErrorKind, - ProviderMetadata, ProviderStatus, -}; -use owlen_core::{Error as CoreError, Result as CoreResult}; -use reqwest::{Client, Method, StatusCode, Url}; -use serde::Deserialize; -use serde_json::{Map as JsonMap, Value}; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; - -const DEFAULT_TIMEOUT_SECS: u64 = 60; - -/// Shared Ollama HTTP client used by both local and cloud providers. -#[derive(Clone)] -pub struct OllamaClient { - http: Client, - base_url: Url, - api_key: Option, - provider_metadata: ProviderMetadata, -} - -impl OllamaClient { - /// Create a new client with the given base URL and optional API key. - pub fn new( - base_url: impl AsRef, - api_key: Option, - provider_metadata: ProviderMetadata, - request_timeout: Option, - ) -> CoreResult { - let base_url = Url::parse(base_url.as_ref()) - .map_err(|err| CoreError::Config(format!("invalid base url: {}", err)))?; - - let timeout = request_timeout.unwrap_or_else(|| Duration::from_secs(DEFAULT_TIMEOUT_SECS)); - let http = Client::builder() - .timeout(timeout) - .build() - .map_err(|err| CoreError::Provider(err.into()))?; - - Ok(Self { - http, - base_url, - api_key, - provider_metadata, - }) - } - - /// Provider metadata associated with this client. - pub fn metadata(&self) -> &ProviderMetadata { - &self.provider_metadata - } - - fn provider_failure( - &self, - kind: ProviderErrorKind, - message: impl Into, - detail: Option, - ) -> CoreError { - let error = - ProviderError::new(kind, message).with_provider(self.provider_metadata.id.clone()); - let error = if let Some(detail) = detail { - error.with_detail(detail) - } else { - error - }; - CoreError::ProviderFailure(error) - } - - fn map_http_error(&self, endpoint: &str, status: StatusCode, body: &[u8]) -> CoreError { - let snippet = truncated_body(body); - match status { - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => self.provider_failure( - ProviderErrorKind::Unauthorized, - format!( - "Ollama {endpoint} request unauthorized (status {status}). Check your API key." - ), - Some(snippet), - ), - StatusCode::TOO_MANY_REQUESTS => self.provider_failure( - ProviderErrorKind::RateLimited, - format!("Ollama {endpoint} request rate limited"), - Some(snippet), - ), - StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT => self.provider_failure( - ProviderErrorKind::Timeout, - format!("Ollama {endpoint} request timed out ({status})."), - Some(snippet), - ), - status if status.is_server_error() => self.provider_failure( - ProviderErrorKind::Unavailable, - format!("Ollama {endpoint} request failed ({status})."), - Some(snippet), - ), - status if status.is_client_error() => self.provider_failure( - ProviderErrorKind::InvalidRequest, - format!("Ollama {endpoint} request rejected ({status})."), - Some(snippet), - ), - _ => self.provider_failure( - ProviderErrorKind::Unknown, - format!("Ollama {endpoint} request failed ({status})."), - Some(snippet), - ), - } - } - - fn map_reqwest_error(&self, action: &str, err: reqwest::Error) -> CoreError { - if err.is_timeout() { - self.provider_failure( - ProviderErrorKind::Timeout, - format!("Ollama {action} timed out"), - Some(err.to_string()), - ) - } else if err.is_connect() || err.is_request() { - self.provider_failure( - ProviderErrorKind::Network, - format!("Ollama {action} request failed"), - Some(err.to_string()), - ) - } else { - CoreError::Provider(err.into()) - } - } - - /// Perform a basic health check to determine provider availability. - pub async fn health_check(&self) -> CoreResult { - let url = self.endpoint("api/tags")?; - - let response = self - .request(Method::GET, url) - .send() - .await - .map_err(|err| self.map_reqwest_error("health check", err))?; - - match response.status() { - status if status.is_success() => Ok(ProviderStatus::Available), - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Ok(ProviderStatus::RequiresSetup), - _ => Ok(ProviderStatus::Unavailable), - } - } - - /// Fetch the available models from the Ollama API. - pub async fn list_models(&self) -> CoreResult> { - let url = self.endpoint("api/tags")?; - - let response = self - .request(Method::GET, url) - .send() - .await - .map_err(|err| self.map_reqwest_error("list models", err))?; - - let status = response.status(); - let bytes = response - .bytes() - .await - .map_err(|err| self.map_reqwest_error("read model list", err))?; - - if !status.is_success() { - return Err(self.map_http_error("tags", status, &bytes)); - } - - let payload: TagsResponse = - serde_json::from_slice(&bytes).map_err(CoreError::Serialization)?; - - let models = payload - .models - .into_iter() - .map(|model| self.parse_model_info(model)) - .collect(); - - Ok(models) - } - - /// Request a streaming generation session from Ollama. - pub async fn generate_stream(&self, request: GenerateRequest) -> CoreResult { - let url = self.endpoint("api/generate")?; - - let body = self.build_generate_body(request); - - let response = self - .request(Method::POST, url) - .json(&body) - .send() - .await - .map_err(|err| self.map_reqwest_error("generate", err))?; - - let status = response.status(); - - if !status.is_success() { - let bytes = response - .bytes() - .await - .map_err(|err| self.map_reqwest_error("read generate body", err))?; - return Err(self.map_http_error("generate", status, &bytes)); - } - - let stream = response.bytes_stream(); - let (tx, rx) = mpsc::channel::>(32); - let client = self.clone(); - - tokio::spawn(async move { - let mut stream = stream; - let mut buffer: Vec = Vec::new(); - - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { - buffer.extend_from_slice(&bytes); - while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') { - let line_bytes: Vec = buffer.drain(..=pos).collect(); - if let Some(line) = prepare_stream_line(&line_bytes) { - match parse_stream_line(&line) { - Ok(item) => { - if tx.send(Ok(item)).await.is_err() { - return; - } - } - Err(err) => { - let _ = tx.send(Err(err)).await; - return; - } - } - } - } - } - Err(err) => { - let _ = tx - .send(Err(client.map_reqwest_error("stream chunk", err))) - .await; - return; - } - } - } - - if !buffer.is_empty() { - let line_bytes = std::mem::take(&mut buffer); - if let Some(line) = prepare_stream_line(&line_bytes) { - match parse_stream_line(&line) { - Ok(item) => { - let _ = tx.send(Ok(item)).await; - } - Err(err) => { - let _ = tx.send(Err(err)).await; - } - } - } - } - }); - - let stream = ReceiverStream::new(rx); - Ok(Box::pin(stream)) - } - - fn request(&self, method: Method, url: Url) -> reqwest::RequestBuilder { - let mut builder = self.http.request(method, url); - if let Some(api_key) = &self.api_key { - builder = builder.bearer_auth(api_key); - } - builder - } - - fn endpoint(&self, path: &str) -> CoreResult { - self.base_url - .join(path) - .map_err(|err| CoreError::Config(format!("invalid endpoint '{}': {}", path, err))) - } - - fn build_generate_body(&self, request: GenerateRequest) -> Value { - let GenerateRequest { - model, - prompt, - context, - parameters, - metadata, - } = request; - - let mut body = JsonMap::new(); - body.insert("model".into(), Value::String(model)); - body.insert("stream".into(), Value::Bool(true)); - - if let Some(prompt) = prompt { - body.insert("prompt".into(), Value::String(prompt)); - } - - if !context.is_empty() { - let items = context.into_iter().map(Value::String).collect(); - body.insert("context".into(), Value::Array(items)); - } - - if !parameters.is_empty() { - body.insert("options".into(), Value::Object(to_json_map(parameters))); - } - - if !metadata.is_empty() { - body.insert("metadata".into(), Value::Object(to_json_map(metadata))); - } - - Value::Object(body) - } - - fn parse_model_info(&self, model: OllamaModel) -> ModelInfo { - let mut metadata = HashMap::new(); - - if let Some(digest) = model.digest { - metadata.insert("digest".to_string(), Value::String(digest)); - } - if let Some(modified) = model.modified_at { - metadata.insert("modified_at".to_string(), Value::String(modified)); - } - if let Some(details) = model.details { - let mut details_map = JsonMap::new(); - if let Some(format) = details.format { - details_map.insert("format".into(), Value::String(format)); - } - if let Some(family) = details.family { - details_map.insert("family".into(), Value::String(family)); - } - if let Some(parameter_size) = details.parameter_size { - details_map.insert("parameter_size".into(), Value::String(parameter_size)); - } - if let Some(quantisation) = details.quantization_level { - details_map.insert("quantization_level".into(), Value::String(quantisation)); - } - - if !details_map.is_empty() { - metadata.insert("details".to_string(), Value::Object(details_map)); - } - } - - ModelInfo { - name: model.name, - size_bytes: model.size, - capabilities: Vec::new(), - description: None, - provider: self.provider_metadata.clone(), - metadata, - } - } -} - -#[derive(Debug, Deserialize)] -struct TagsResponse { - #[serde(default)] - models: Vec, -} - -#[derive(Debug, Deserialize)] -struct OllamaModel { - name: String, - #[serde(default)] - size: Option, - #[serde(default)] - digest: Option, - #[serde(default)] - modified_at: Option, - #[serde(default)] - details: Option, -} - -#[derive(Debug, Deserialize)] -struct OllamaModelDetails { - #[serde(default)] - format: Option, - #[serde(default)] - family: Option, - #[serde(default)] - parameter_size: Option, - #[serde(default)] - quantization_level: Option, -} - -fn to_json_map(source: HashMap) -> JsonMap { - source.into_iter().collect() -} - -fn to_metadata_map(value: &Value) -> HashMap { - let mut metadata = HashMap::new(); - - if let Value::Object(obj) = value { - for (key, item) in obj { - if key == "response" || key == "done" { - continue; - } - metadata.insert(key.clone(), item.clone()); - } - } - - metadata -} - -fn prepare_stream_line(bytes: &[u8]) -> Option { - if bytes.is_empty() { - return None; - } - - let mut line = String::from_utf8_lossy(bytes).into_owned(); - - while line.ends_with('\n') || line.ends_with('\r') { - line.pop(); - } - - if line.trim().is_empty() { - return None; - } - - Some(line) -} - -fn log_stream_decode_error(line: &str, err: &serde_json::Error) { - const MAX_PREVIEW_CHARS: usize = 256; - - let total_chars = line.chars().count(); - let truncated = total_chars > MAX_PREVIEW_CHARS; - let mut preview: String = line.chars().take(MAX_PREVIEW_CHARS).collect(); - - if truncated { - preview.push_str("..."); - } - - let preview = preview - .replace('\n', "\\n") - .replace('\r', "\\r") - .replace('\t', "\\t"); - - warn!( - "Failed to parse Ollama stream chunk ({} chars): {}. Preview: \"{}\"", - total_chars, err, preview - ); -} - -fn parse_stream_line(line: &str) -> CoreResult { - let value: Value = serde_json::from_str(line).map_err(|err| { - log_stream_decode_error(line, &err); - CoreError::Serialization(err) - })?; - - if let Some(error) = value.get("error").and_then(Value::as_str) { - return Err(CoreError::Provider(anyhow!( - "ollama generation error: {}", - error - ))); - } - - let mut chunk = GenerateChunk { - text: value - .get("response") - .and_then(Value::as_str) - .map(str::to_string), - is_final: value.get("done").and_then(Value::as_bool).unwrap_or(false), - metadata: to_metadata_map(&value), - }; - - if chunk.is_final { - if let Some(Value::Object(done_obj)) = value.get("done") { - for (key, item) in done_obj { - chunk.metadata.insert(key.clone(), item.clone()); - } - } - - if chunk.text.is_none() && chunk.metadata.is_empty() { - chunk - .metadata - .insert("status".into(), Value::String("done".into())); - } - } - - Ok(chunk) -} - -fn truncated_body(body: &[u8]) -> String { - const MAX_CHARS: usize = 512; - let text = String::from_utf8_lossy(body); - let mut value = String::new(); - for (idx, ch) in text.chars().enumerate() { - if idx >= MAX_CHARS { - value.push('…'); - return value; - } - value.push(ch); - } - value -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn prepare_stream_line_preserves_leading_whitespace() { - let mut bytes = br#"{"response":" fn main() {}\n","done":false}"#.to_vec(); - bytes.extend_from_slice(b"\r\n"); - - let line = prepare_stream_line(&bytes).expect("line should be parsed"); - assert!(line.starts_with(r#"{"response""#)); - assert!(line.ends_with(r#""done":false}"#)); - - let chunk = parse_stream_line(&line).expect("chunk should parse"); - assert_eq!( - chunk.text.as_deref(), - Some(" fn main() {}\n"), - "leading indentation must be preserved" - ); - assert!(!chunk.is_final); - } - - #[test] - fn parse_stream_line_handles_samples_fixture() { - let data = include_str!("../../../../samples.json"); - let values: Vec = - serde_json::from_str(data).expect("samples fixture should be valid json"); - - let mut chunks = Vec::new(); - for value in values { - let line = serde_json::to_string(&value).expect("serialize chunk"); - let chunk = parse_stream_line(&line).expect("parse chunk"); - chunks.push(chunk); - } - - assert!( - !chunks.is_empty(), - "fixture must produce at least one chunk" - ); - assert_eq!( - chunks[0].text.as_deref(), - Some("first"), - "first chunk should match fixture payload" - ); - - let final_chunk = chunks.last().expect("final chunk must exist"); - assert!( - final_chunk.is_final, - "last chunk should be marked final per fixture" - ); - assert!( - final_chunk.text.as_deref().unwrap_or_default().is_empty(), - "final chunk should not include stray text" - ); - assert!( - final_chunk.metadata.contains_key("final_data"), - "final chunk should surface metadata from fixture" - ); - } -} diff --git a/crates/owlen-providers/tests/common/mock_provider.rs b/crates/owlen-providers/tests/common/mock_provider.rs deleted file mode 100644 index 867015f..0000000 --- a/crates/owlen-providers/tests/common/mock_provider.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use futures::stream::{self, StreamExt}; -use owlen_core::Result as CoreResult; -use owlen_core::provider::{ - GenerateChunk, GenerateRequest, GenerateStream, ModelInfo, ModelProvider, ProviderMetadata, - ProviderStatus, ProviderType, -}; - -pub struct MockProvider { - metadata: ProviderMetadata, - models: Vec, - status: ProviderStatus, - #[allow(clippy::type_complexity)] - generate_handler: Option Vec + Send + Sync>>, - generate_error: Option owlen_core::Error + Send + Sync>>, -} - -impl MockProvider { - pub fn new(id: &str) -> Self { - let metadata = ProviderMetadata::new( - id, - format!("Mock Provider ({})", id), - ProviderType::Local, - false, - ); - - Self { - metadata, - models: vec![ModelInfo { - name: format!("{}-primary", id), - size_bytes: None, - capabilities: vec!["chat".into()], - description: Some("Mock model".into()), - provider: ProviderMetadata::new(id, "Mock", ProviderType::Local, false), - metadata: Default::default(), - }], - status: ProviderStatus::Available, - generate_handler: None, - generate_error: None, - } - } - - pub fn with_models(mut self, models: Vec) -> Self { - self.models = models; - self - } - - pub fn with_status(mut self, status: ProviderStatus) -> Self { - self.status = status; - self - } - - pub fn with_generate_handler(mut self, handler: F) -> Self - where - F: Fn(GenerateRequest) -> Vec + Send + Sync + 'static, - { - self.generate_handler = Some(Arc::new(handler)); - self - } - - pub fn with_generate_error(mut self, factory: F) -> Self - where - F: Fn() -> owlen_core::Error + Send + Sync + 'static, - { - self.generate_error = Some(Arc::new(factory)); - self - } -} - -#[async_trait] -impl ModelProvider for MockProvider { - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - - async fn health_check(&self) -> CoreResult { - Ok(self.status) - } - - async fn list_models(&self) -> CoreResult> { - Ok(self.models.clone()) - } - - async fn generate_stream(&self, request: GenerateRequest) -> CoreResult { - if let Some(factory) = &self.generate_error { - return Err(factory()); - } - - let chunks = if let Some(handler) = &self.generate_handler { - (handler)(request) - } else { - vec![GenerateChunk::final_chunk()] - }; - - let stream = stream::iter(chunks.into_iter().map(Ok)).boxed(); - Ok(Box::pin(stream)) - } -} - -impl From for Arc { - fn from(provider: MockProvider) -> Self { - Arc::new(provider) - } -} diff --git a/crates/owlen-providers/tests/common/mod.rs b/crates/owlen-providers/tests/common/mod.rs deleted file mode 100644 index d7c1429..0000000 --- a/crates/owlen-providers/tests/common/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod mock_provider; diff --git a/crates/owlen-providers/tests/integration_test.rs b/crates/owlen-providers/tests/integration_test.rs deleted file mode 100644 index 5114369..0000000 --- a/crates/owlen-providers/tests/integration_test.rs +++ /dev/null @@ -1,117 +0,0 @@ -mod common; - -use std::sync::Arc; - -use futures::StreamExt; - -use common::mock_provider::MockProvider; -use owlen_core::config::Config; -use owlen_core::provider::{ - GenerateChunk, GenerateRequest, ModelInfo, ProviderManager, ProviderType, -}; - -#[allow(dead_code)] -fn base_config() -> Config { - Config { - providers: Default::default(), - ..Default::default() - } -} - -fn make_model(name: &str, provider: &str) -> ModelInfo { - ModelInfo { - name: name.into(), - size_bytes: None, - capabilities: vec!["chat".into()], - description: Some("mock".into()), - provider: owlen_core::provider::ProviderMetadata::new( - provider, - provider, - ProviderType::Local, - false, - ), - metadata: Default::default(), - } -} - -#[tokio::test] -async fn registers_providers_and_lists_ids() { - let manager = ProviderManager::default(); - let provider: Arc = MockProvider::new("mock-a").into(); - - manager.register_provider(provider).await; - let ids = manager.provider_ids().await; - - assert_eq!(ids, vec!["mock-a".to_string()]); -} - -#[tokio::test] -async fn aggregates_models_across_providers() { - let manager = ProviderManager::default(); - let provider_a = MockProvider::new("mock-a").with_models(vec![make_model("alpha", "mock-a")]); - let provider_b = MockProvider::new("mock-b").with_models(vec![make_model("beta", "mock-b")]); - - manager.register_provider(provider_a.into()).await; - manager.register_provider(provider_b.into()).await; - - let models = manager.list_all_models().await.unwrap(); - assert_eq!(models.len(), 2); - assert!(models.iter().any(|m| m.model.name == "alpha")); - assert!(models.iter().any(|m| m.model.name == "beta")); -} - -#[tokio::test] -async fn routes_generation_to_specific_provider() { - let manager = ProviderManager::default(); - let provider = MockProvider::new("mock-gen").with_generate_handler(|_req| { - vec![ - GenerateChunk::from_text("hello"), - GenerateChunk::final_chunk(), - ] - }); - - manager.register_provider(provider.into()).await; - - let request = GenerateRequest::new("mock-gen::primary"); - let mut stream = manager.generate("mock-gen", request).await.unwrap(); - let mut collected = Vec::new(); - while let Some(chunk) = stream.next().await { - collected.push(chunk.unwrap()); - } - - assert_eq!(collected.len(), 2); - assert_eq!(collected[0].text.as_deref(), Some("hello")); - assert!(collected[1].is_final); -} - -#[tokio::test] -async fn marks_provider_unavailable_on_error() { - let manager = ProviderManager::default(); - let provider = MockProvider::new("flaky") - .with_generate_error(|| owlen_core::Error::Network("boom".into())); - - manager.register_provider(provider.into()).await; - let request = GenerateRequest::new("flaky::model"); - let result = manager.generate("flaky", request).await; - assert!(result.is_err()); - - let status = manager.provider_status("flaky").await.unwrap(); - assert!(matches!( - status, - owlen_core::provider::ProviderStatus::Unavailable - )); -} - -#[tokio::test] -async fn health_refresh_updates_status_cache() { - let manager = ProviderManager::default(); - let provider = - MockProvider::new("healthy").with_status(owlen_core::provider::ProviderStatus::Available); - - manager.register_provider(provider.into()).await; - let statuses = manager.refresh_health().await; - assert_eq!( - statuses.get("healthy"), - Some(&owlen_core::provider::ProviderStatus::Available) - ); -} diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml deleted file mode 100644 index fa7c669..0000000 --- a/crates/owlen-tui/Cargo.toml +++ /dev/null @@ -1,55 +0,0 @@ -[package] -name = "owlen-tui" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Terminal User Interface for OWLEN LLM client" - -[dependencies] -owlen-core = { path = "../owlen-core" } -# Removed owlen-ollama dependency - all providers now accessed via MCP architecture (Phase 10) - -# TUI framework -ratatui = { workspace = true } -crossterm = { workspace = true } -tui-textarea = { workspace = true } -textwrap = { workspace = true } -unicode-width = "0.2" -unicode-segmentation = "1.11" -async-trait = "0.1" -globset = "0.4" -ignore = "0.4" -pathdiff = "0.2" -tree-sitter = "0.25" -tree-sitter-rust = "0.20" -dirs = { workspace = true } -toml = { workspace = true } -syntect = "5.3" -once_cell = "1.19" -owlen-markdown = { path = "../owlen-markdown" } -shellexpand = { workspace = true } -regex = { workspace = true } - -# Async runtime -tokio = { workspace = true } -tokio-util = { workspace = true } -futures-util = { workspace = true } - -# Utilities -anyhow = { workspace = true } -uuid = { workspace = true } -serde_json.workspace = true -serde.workspace = true -chrono = { workspace = true } -log = { workspace = true } -base64 = { workspace = true } -mime_guess = { workspace = true } -image = { workspace = true } - -[dev-dependencies] -tokio-test = { workspace = true } -tempfile = { workspace = true } -insta = { version = "1.40", features = ["glob"] } diff --git a/crates/owlen-tui/README.md b/crates/owlen-tui/README.md deleted file mode 100644 index 64cded5..0000000 --- a/crates/owlen-tui/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Owlen TUI - -This crate contains all the logic for the terminal user interface (TUI) of Owlen. - -It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for rendering the chat interface, handling user input, and managing the application state. - -## Features - -- **Chat View**: A scrollable view of the conversation history. -- **Input Box**: A text input area for composing messages. -- **Model Selection**: An interface for switching between different models. -- **Event Handling**: A system for managing keyboard events and asynchronous operations. diff --git a/crates/owlen-tui/keymap.toml b/crates/owlen-tui/keymap.toml deleted file mode 100644 index 4ba33fd..0000000 --- a/crates/owlen-tui/keymap.toml +++ /dev/null @@ -1,210 +0,0 @@ -[[binding]] -modes = ["normal"] -command = "model.open_all" -sequences = [ - ["m"], - ["", "m"] -] - -[[binding]] -modes = ["normal"] -command = "model.open_local" -sequences = [ - ["Ctrl+Shift+L"], - ["", "m", "l"] -] - -[[binding]] -modes = ["normal"] -command = "model.open_cloud" -sequences = [ - ["Ctrl+Shift+C"], - ["", "m", "c"] -] - -[[binding]] -modes = ["normal"] -command = "model.open_available" -sequences = [ - ["Ctrl+Shift+P"], - ["", "m", "a"] -] - -[[binding]] -modes = ["normal", "editing"] -command = "palette.open" -sequences = [ - ["Ctrl+P"], - ["", "t"] -] - -[[binding]] -modes = ["normal"] -command = "provider.switch" -sequence = ["", "p"] - -[[binding]] -modes = ["normal"] -sequence = ["Tab"] -command = "focus.next" - -[[binding]] -modes = ["normal"] -sequence = ["Shift+Tab"] -command = "focus.prev" - -[[binding]] -modes = ["normal"] -command = "focus.files" -sequences = [ - ["Ctrl+1"], - ["", "f", "1"] -] - -[[binding]] -modes = ["normal"] -command = "focus.chat" -sequences = [ - ["Ctrl+2"], - ["", "f", "2"] -] - -[[binding]] -modes = ["normal"] -command = "focus.code" -sequences = [ - ["Ctrl+3"], - ["", "f", "3"] -] - -[[binding]] -modes = ["normal"] -command = "focus.thinking" -sequences = [ - ["Ctrl+4"], - ["", "f", "4"] -] - -[[binding]] -modes = ["normal"] -command = "focus.input" -sequences = [ - ["Ctrl+5"], - ["", "f", "5"] -] - -[[binding]] -modes = ["editing"] -command = "composer.submit" -sequence = ["Enter"] - -[[binding]] -modes = ["normal"] -command = "mode.command" -sequences = [ - ["Ctrl+;"], - ["", ":"] -] - -[[binding]] -modes = ["normal", "editing", "visual", "command", "help"] -sequence = ["F12"] -command = "debug.toggle" - -[[binding]] -modes = ["normal"] -command = "navigate.top" -sequences = [ - ["g", "g"], - ["", "g", "t"] -] - -[[binding]] -modes = ["normal"] -command = "navigate.bottom" -sequences = [ - ["Shift+G"], - ["", "g", "b"] -] - -[[binding]] -modes = ["normal"] -command = "files.focus_expand" -sequences = [ - ["g", "t"], - ["g", "T"], - ["", "f", "e"] -] - -[[binding]] -modes = ["normal"] -command = "files.toggle_hidden" -sequences = [ - ["g", "h"], - ["g", "H"], - ["", "f", "h"] -] - -[[binding]] -modes = ["normal"] -command = "input.clear" -sequences = [ - ["d", "d"], - ["", "m", "d"] -] - -[[binding]] -modes = ["normal"] -command = "workspace.split_horizontal" -sequences = [ - ["Ctrl+W", "s"], - ["Ctrl+W", "S"], - ["", "l", "s"] -] -timeout_ms = 1200 - -[[binding]] -modes = ["normal"] -command = "workspace.split_vertical" -sequences = [ - ["Ctrl+W", "v"], - ["Ctrl+W", "V"], - ["", "l", "v"] -] -timeout_ms = 1200 - -[[binding]] -modes = ["normal"] -command = "workspace.focus_left" -sequences = [ - ["Ctrl+K", "Left"], - ["", "l", "h"] -] -timeout_ms = 1200 - -[[binding]] -modes = ["normal"] -command = "workspace.focus_right" -sequences = [ - ["Ctrl+K", "Right"], - ["", "l", "l"] -] -timeout_ms = 1200 - -[[binding]] -modes = ["normal"] -command = "workspace.focus_up" -sequences = [ - ["Ctrl+K", "Up"], - ["", "l", "k"] -] -timeout_ms = 1200 - -[[binding]] -modes = ["normal"] -command = "workspace.focus_down" -sequences = [ - ["Ctrl+K", "Down"], - ["", "l", "j"] -] -timeout_ms = 1200 diff --git a/crates/owlen-tui/keymap_emacs.toml b/crates/owlen-tui/keymap_emacs.toml deleted file mode 100644 index 71d2e8f..0000000 --- a/crates/owlen-tui/keymap_emacs.toml +++ /dev/null @@ -1,159 +0,0 @@ -[[binding]] -mode = "normal" -command = "model.open_all" -sequences = [ - ["Alt+M"], - ["Ctrl+X", "m"] -] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "model.open_local" -sequences = [ - ["Ctrl+Alt+L"], - ["Ctrl+X", "l"] -] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "model.open_cloud" -sequences = [ - ["Ctrl+Alt+C"], - ["Ctrl+X", "c"] -] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "model.open_available" -sequences = [ - ["Ctrl+Alt+A"], - ["Ctrl+X", "a"] -] -timeout_ms = 1200 - -[[binding]] -modes = ["normal", "editing"] -sequence = ["Ctrl+Space"] -command = "palette.open" - -[[binding]] -mode = "normal" -command = "provider.switch" -sequences = [ - ["Ctrl+X", "Ctrl+P"] -] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -sequence = ["Alt+O"] -command = "focus.next" - -[[binding]] -mode = "normal" -sequence = ["Alt+Shift+O"] -command = "focus.prev" - -[[binding]] -mode = "normal" -command = "focus.files" -sequence = ["Alt+1"] - -[[binding]] -mode = "normal" -command = "focus.chat" -sequence = ["Alt+2"] - -[[binding]] -mode = "normal" -command = "focus.code" -sequence = ["Alt+3"] - -[[binding]] -mode = "normal" -command = "focus.thinking" -sequence = ["Alt+4"] - -[[binding]] -mode = "normal" -command = "focus.input" -sequence = ["Alt+5"] - -[[binding]] -mode = "editing" -command = "composer.submit" -sequences = [ - ["Ctrl+Enter"], - ["Ctrl+X", "Ctrl+S"] -] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "mode.command" -sequence = ["Alt+x"] - -[[binding]] -modes = ["normal", "editing", "visual", "command", "help"] -sequence = ["F12"] -command = "debug.toggle" - -[[binding]] -mode = "normal" -command = "files.focus_expand" -sequences = [ - ["Ctrl+X", "Ctrl+F"] -] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "input.clear" -sequences = [ - ["Alt+Backspace"], - ["Ctrl+X", "k"] -] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "workspace.split_horizontal" -sequence = ["Ctrl+X", "2"] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "workspace.split_vertical" -sequence = ["Ctrl+X", "3"] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "workspace.focus_right" -sequence = ["Ctrl+X", "o"] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "workspace.focus_left" -sequence = ["Ctrl+X", "Shift+O"] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "navigate.top" -sequences = [ - ["Alt+G", "g"] -] -timeout_ms = 1200 - -[[binding]] -mode = "normal" -command = "navigate.bottom" -sequences = [ - ["Alt+G", "Shift+G"] -] -timeout_ms = 1200 diff --git a/crates/owlen-tui/src/app/generation.rs b/crates/owlen-tui/src/app/generation.rs deleted file mode 100644 index f7846b0..0000000 --- a/crates/owlen-tui/src/app/generation.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use futures_util::StreamExt; -use owlen_core::provider::GenerateRequest; -use uuid::Uuid; - -use super::{ActiveGeneration, App, AppMessage}; - -impl App { - /// Kick off a new generation task on the supplied provider. - pub fn start_generation( - &mut self, - provider_id: impl Into, - request: GenerateRequest, - ) -> Result { - let provider_id = provider_id.into(); - let request_id = Uuid::new_v4(); - - // Cancel any existing task so we don't interleave output. - if let Some(active) = self.active_generation.take() { - active.abort(); - } - - self.message_tx - .try_send(AppMessage::GenerateStart { - request_id, - provider_id: provider_id.clone(), - request: request.clone(), - }) - .map_err(|err| anyhow!("failed to queue generation start: {err:?}"))?; - - let manager = Arc::clone(&self.provider_manager); - let message_tx = self.message_tx.clone(); - let provider_for_task = provider_id.clone(); - - let join_handle = tokio::spawn(async move { - let mut stream = match manager.generate(&provider_for_task, request).await { - Ok(stream) => stream, - Err(err) => { - let _ = message_tx.send(AppMessage::GenerateError { - request_id: Some(request_id), - message: err.to_string(), - }).await; - return; - } - }; - - while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => { - if message_tx - .send(AppMessage::GenerateChunk { request_id, chunk }) - .await - .is_err() - { - break; - } - } - Err(err) => { - let _ = message_tx.send(AppMessage::GenerateError { - request_id: Some(request_id), - message: err.to_string(), - }).await; - return; - } - } - } - - let _ = message_tx.send(AppMessage::GenerateComplete { request_id }).await; - }); - - let generation = ActiveGeneration::new(request_id, provider_id, join_handle); - self.active_generation = Some(generation); - - Ok(request_id) - } -} diff --git a/crates/owlen-tui/src/app/handler.rs b/crates/owlen-tui/src/app/handler.rs deleted file mode 100644 index 6d80d0f..0000000 --- a/crates/owlen-tui/src/app/handler.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::{App, messages::AppMessage}; -use log::warn; -use owlen_core::{ - provider::{GenerateChunk, GenerateRequest, ProviderStatus}, - state::AppState, -}; -use uuid::Uuid; - -/// Trait implemented by UI state containers to react to [`AppMessage`] events. -pub trait MessageState { - /// Called when a generation request is about to start. - #[allow(unused_variables)] - fn start_generation( - &mut self, - request_id: Uuid, - provider_id: &str, - request: &GenerateRequest, - ) -> AppState { - AppState::Running - } - - /// Called for every streamed generation chunk. - #[allow(unused_variables)] - fn append_chunk(&mut self, request_id: Uuid, chunk: &GenerateChunk) -> AppState { - AppState::Running - } - - /// Called when a generation finishes successfully. - #[allow(unused_variables)] - fn generation_complete(&mut self, request_id: Uuid) -> AppState { - AppState::Running - } - - /// Called when a generation fails. - #[allow(unused_variables)] - fn generation_failed(&mut self, request_id: Option, message: &str) -> AppState { - AppState::Running - } - - /// Called when refreshed model metadata is available. - fn update_model_list(&mut self) -> AppState { - AppState::Running - } - - /// Called when a models refresh has been requested. - fn refresh_model_list(&mut self) -> AppState { - AppState::Running - } - - /// Called when provider status updates arrive. - #[allow(unused_variables)] - fn update_provider_status(&mut self, provider_id: &str, status: ProviderStatus) -> AppState { - AppState::Running - } - - /// Called when a resize event occurs. - #[allow(unused_variables)] - fn handle_resize(&mut self, width: u16, height: u16) -> AppState { - AppState::Running - } - - /// Called on periodic ticks. - fn handle_tick(&mut self) -> AppState { - AppState::Running - } -} - -impl App { - /// Dispatch a message to the provided [`MessageState`]. Returns `true` when the - /// state indicates the UI should exit. - pub fn handle_message(&mut self, state: &mut State, message: AppMessage) -> bool - where - State: MessageState, - { - use AppMessage::*; - - let outcome = match message { - KeyPress(_) => AppState::Running, - Resize { width, height } => state.handle_resize(width, height), - Tick => state.handle_tick(), - GenerateStart { - request_id, - provider_id, - request, - } => state.start_generation(request_id, &provider_id, &request), - GenerateChunk { request_id, chunk } => state.append_chunk(request_id, &chunk), - GenerateComplete { request_id } => { - self.clear_active_generation(request_id); - state.generation_complete(request_id) - } - GenerateError { - request_id, - message, - } => { - self.clear_active_generation_optional(request_id); - state.generation_failed(request_id, &message) - } - ModelsRefresh => state.refresh_model_list(), - ModelsUpdated => state.update_model_list(), - ProviderStatus { - provider_id, - status, - } => state.update_provider_status(&provider_id, status), - }; - - matches!(outcome, AppState::Quit) - } - - fn clear_active_generation(&mut self, request_id: Uuid) { - if self - .active_generation - .as_ref() - .map(|active| active.request_id() == request_id) - .unwrap_or(false) - { - self.active_generation = None; - } else { - warn!( - "received completion for unknown request {}, ignoring", - request_id - ); - } - } - - fn clear_active_generation_optional(&mut self, request_id: Option) { - match request_id { - Some(id) => self.clear_active_generation(id), - None => { - if self.active_generation.is_some() { - self.active_generation = None; - } - } - } - } -} diff --git a/crates/owlen-tui/src/app/messages.rs b/crates/owlen-tui/src/app/messages.rs deleted file mode 100644 index 2a07454..0000000 --- a/crates/owlen-tui/src/app/messages.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crossterm::event::KeyEvent; -use owlen_core::provider::{GenerateChunk, GenerateRequest, ProviderStatus}; -use uuid::Uuid; - -/// Messages exchanged between the UI event loop and background workers. -#[derive(Debug)] -pub enum AppMessage { - /// User input event bubbled up from the terminal layer. - KeyPress(KeyEvent), - /// Terminal resize notification. - Resize { width: u16, height: u16 }, - /// Periodic tick used to drive animations. - Tick, - /// Initiate a new text generation request. - GenerateStart { - request_id: Uuid, - provider_id: String, - request: GenerateRequest, - }, - /// Streamed response chunk from the active generation task. - GenerateChunk { - request_id: Uuid, - chunk: GenerateChunk, - }, - /// Generation finished successfully. - GenerateComplete { request_id: Uuid }, - /// Generation failed or was aborted. - GenerateError { - request_id: Option, - message: String, - }, - /// Trigger a background refresh of available models. - ModelsRefresh, - /// New model list data is ready. - ModelsUpdated, - /// Provider health status update. - ProviderStatus { - provider_id: String, - status: ProviderStatus, - }, -} diff --git a/crates/owlen-tui/src/app/mod.rs b/crates/owlen-tui/src/app/mod.rs deleted file mode 100644 index 6f04d94..0000000 --- a/crates/owlen-tui/src/app/mod.rs +++ /dev/null @@ -1,399 +0,0 @@ -mod generation; -mod handler; -pub mod mvu; -mod worker; - -pub mod messages; -pub use worker::background_worker; - -use std::{ - io, - sync::{Arc, Mutex}, - time::Duration, -}; - -use anyhow::Result; -use async_trait::async_trait; -use crossterm::event; -use owlen_core::{provider::ProviderManager, state::AppState}; -use ratatui::{Terminal, backend::CrosstermBackend}; -use tokio::{ - sync::mpsc, - task::{self, AbortHandle, JoinHandle}, - time::{MissedTickBehavior, interval}, -}; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -use crate::{Event, SessionEvent, events}; - -pub use handler::MessageState; -pub use messages::AppMessage; - -use std::sync::atomic::{AtomicBool, Ordering}; - -#[derive(Debug)] -enum AppEvent { - Message(AppMessage), - Session(SessionEvent), - Ui(Event), - FrameTick, - RedrawRequested, -} - -#[derive(Debug, Clone, Copy)] -enum LoopControl { - Continue, - Exit(AppState), -} - -#[async_trait] -pub trait UiRuntime: MessageState { - async fn handle_ui_event(&mut self, event: Event) -> Result; - async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()>; - async fn process_pending_llm_request(&mut self) -> Result<()>; - async fn process_pending_tool_execution(&mut self) -> Result<()>; - fn poll_controller_events(&mut self) -> Result<()>; - fn advance_loading_animation(&mut self); - fn streaming_count(&self) -> usize; -} - -/// High-level application state driving the non-blocking TUI. -pub struct App { - provider_manager: Arc, - message_tx: mpsc::Sender, - message_rx: Option>, - active_generation: Option, - frame_requester: FrameRequester, -} - -impl App { - /// Construct a new application instance with an associated message channel. - pub fn new(provider_manager: Arc) -> Self { - let (message_tx, message_rx) = mpsc::channel(256); - - Self { - provider_manager, - message_tx, - message_rx: Some(message_rx), - active_generation: None, - frame_requester: FrameRequester::new(), - } - } - - /// Cloneable sender handle for pushing messages into the application loop. - pub fn message_sender(&self) -> mpsc::Sender { - self.message_tx.clone() - } - - /// Handle used by UI state to request redraws. - pub fn frame_requester(&self) -> FrameRequester { - self.frame_requester.clone() - } - - /// Whether a generation task is currently in flight. - pub fn has_active_generation(&self) -> bool { - self.active_generation.is_some() - } - - /// Abort any in-flight generation task. - pub fn abort_active_generation(&mut self) { - if let Some(active) = self.active_generation.take() { - active.abort(); - } - } - - /// Launch the background worker responsible for provider health checks. - pub fn spawn_background_worker(&self) -> JoinHandle<()> { - let manager = Arc::clone(&self.provider_manager); - let sender = self.message_tx.clone(); - - tokio::spawn(async move { - worker::background_worker(manager, sender).await; - }) - } - - /// Drive the main UI loop, handling terminal events, background messages, and - /// provider status updates without blocking rendering. - pub async fn run( - &mut self, - terminal: &mut Terminal>, - state: &mut State, - session_rx: &mut mpsc::UnboundedReceiver, - mut render: RenderFn, - ) -> Result - where - State: UiRuntime, - RenderFn: FnMut(&mut Terminal>, &mut State) -> Result<()>, - { - let mut message_rx = self - .message_rx - .take() - .expect("App::run called without an available message receiver"); - - let (app_event_tx, mut app_event_rx) = mpsc::channel::(64); - self.frame_requester.install(app_event_tx.clone()); - let (input_cancel, input_handle) = Self::spawn_input_listener(app_event_tx.clone()); - drop(app_event_tx); - - let mut frame_interval = interval(Duration::from_millis(16)); - frame_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - - let mut worker_handle = Some(self.spawn_background_worker()); - let mut exit_state = AppState::Quit; - - loop { - self.pump_background(state).await?; - - let next_event = tokio::select! { - Some(event) = app_event_rx.recv() => { - if matches!(event, AppEvent::RedrawRequested) { - self.frame_requester.consume_pending(); - } - event - }, - Some(message) = message_rx.recv() => AppEvent::Message(message), - Some(session_event) = session_rx.recv() => AppEvent::Session(session_event), - _ = frame_interval.tick() => AppEvent::FrameTick, - else => break, - }; - - let should_render = - matches!(next_event, AppEvent::FrameTick | AppEvent::RedrawRequested); - - match self.dispatch_app_event(state, next_event).await? { - LoopControl::Continue => { - if should_render { - render(terminal, state)?; - self.frame_requester.mark_rendered(); - } - } - LoopControl::Exit(state_value) => { - exit_state = state_value; - break; - } - } - } - - input_cancel.cancel(); - let _ = input_handle.await; - - if let Some(handle) = worker_handle.take() { - handle.abort(); - let _ = handle.await; - } - self.frame_requester.detach(); - - self.message_rx = Some(message_rx); - - Ok(exit_state) - } - - async fn pump_background(&mut self, state: &mut State) -> Result<()> - where - State: UiRuntime, - { - state.advance_loading_animation(); - state.process_pending_llm_request().await?; - state.process_pending_tool_execution().await?; - state.poll_controller_events()?; - Ok(()) - } - - async fn dispatch_app_event( - &mut self, - state: &mut State, - event: AppEvent, - ) -> Result - where - State: UiRuntime, - { - let control = match event { - AppEvent::Message(message) => { - if self.handle_message(state, message) { - LoopControl::Exit(AppState::Quit) - } else { - LoopControl::Continue - } - } - AppEvent::Session(session_event) => { - state.handle_session_event(session_event).await?; - LoopControl::Continue - } - AppEvent::Ui(ui_event) => { - match &ui_event { - Event::Key(key) => { - let _ = self.message_tx.send(AppMessage::KeyPress(*key)).await; - } - Event::Resize(width, height) => { - let _ = self.message_tx.send(AppMessage::Resize { - width: *width, - height: *height, - }).await; - } - Event::Tick => { - if self.handle_message(state, AppMessage::Tick) { - return Ok(LoopControl::Exit(AppState::Quit)); - } - return Ok(LoopControl::Continue); - } - _ => {} - } - - let outcome = state.handle_ui_event(ui_event).await?; - if matches!(outcome, AppState::Quit) { - LoopControl::Exit(outcome) - } else { - LoopControl::Continue - } - } - AppEvent::FrameTick => { - if self.handle_message(state, AppMessage::Tick) { - LoopControl::Exit(AppState::Quit) - } else { - LoopControl::Continue - } - } - AppEvent::RedrawRequested => LoopControl::Continue, - }; - - Ok(control) - } - - fn spawn_input_listener( - sender: mpsc::Sender, - ) -> (CancellationToken, JoinHandle<()>) { - let cancellation = CancellationToken::new(); - let handle = task::spawn_blocking({ - let cancellation = cancellation.clone(); - move || { - let poll_interval = Duration::from_millis(16); - while !cancellation.is_cancelled() { - match event::poll(poll_interval) { - Ok(true) => match event::read() { - Ok(raw_event) => { - if let Some(ui_event) = events::from_crossterm_event(raw_event) - && sender.try_send(AppEvent::Ui(ui_event)).is_err() - { - break; - } - } - Err(_) => continue, - }, - Ok(false) => {} - Err(_) => {} - } - } - } - }); - - (cancellation, handle) - } -} - -struct ActiveGeneration { - request_id: Uuid, - #[allow(dead_code)] - provider_id: String, - abort_handle: AbortHandle, - #[allow(dead_code)] - join_handle: JoinHandle<()>, -} - -impl ActiveGeneration { - fn new(request_id: Uuid, provider_id: String, join_handle: JoinHandle<()>) -> Self { - let abort_handle = join_handle.abort_handle(); - Self { - request_id, - provider_id, - abort_handle, - join_handle, - } - } - - fn abort(self) { - self.abort_handle.abort(); - } - - fn request_id(&self) -> Uuid { - self.request_id - } -} - -#[derive(Clone, Debug)] -pub struct FrameRequester { - inner: Arc, -} - -#[derive(Debug)] -struct FrameRequesterInner { - sender: Mutex>>, - pending: AtomicBool, -} - -impl FrameRequester { - fn new() -> Self { - Self { - inner: Arc::new(FrameRequesterInner { - sender: Mutex::new(None), - pending: AtomicBool::new(false), - }), - } - } - - fn install(&self, sender: mpsc::Sender) { - let mut guard = self.inner.sender.lock().expect("frame sender poisoned"); - *guard = Some(sender); - } - - pub fn detach(&self) { - let mut guard = self.inner.sender.lock().expect("frame sender poisoned"); - guard.take(); - self.inner.pending.store(false, Ordering::SeqCst); - } - - pub fn request_frame(&self) { - if self - .inner - .pending - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - let sender = { - self.inner - .sender - .lock() - .expect("frame sender poisoned") - .clone() - }; - if let Some(tx) = sender - && tx.try_send(AppEvent::RedrawRequested).is_ok() - { - return; - } - - // Failed to dispatch; clear pending flag so future attempts can retry. - self.inner.pending.store(false, Ordering::SeqCst); - } - - fn consume_pending(&self) { - // Retain pending flag until we actually render, but ensure a direct request - // without an active sender doesn't lock out future attempts. - if self - .inner - .sender - .lock() - .expect("frame sender poisoned") - .is_none() - { - self.inner.pending.store(false, Ordering::SeqCst); - } - } - - fn mark_rendered(&self) { - self.inner.pending.store(false, Ordering::SeqCst); - } -} diff --git a/crates/owlen-tui/src/app/mvu.rs b/crates/owlen-tui/src/app/mvu.rs deleted file mode 100644 index 277bce9..0000000 --- a/crates/owlen-tui/src/app/mvu.rs +++ /dev/null @@ -1,165 +0,0 @@ -use owlen_core::{consent::ConsentScope, ui::InputMode}; -use uuid::Uuid; - -#[derive(Debug, Clone, Default)] -pub struct AppModel { - pub composer: ComposerModel, -} - -#[derive(Debug, Clone)] -pub struct ComposerModel { - pub draft: String, - pub pending_submit: bool, - pub mode: InputMode, -} - -impl Default for ComposerModel { - fn default() -> Self { - Self { - draft: String::new(), - pending_submit: false, - mode: InputMode::Normal, - } - } -} - -#[derive(Debug, Clone)] -pub enum AppEvent { - Composer(ComposerEvent), - ToolPermission { - request_id: Uuid, - scope: ConsentScope, - }, -} - -#[derive(Debug, Clone)] -pub enum ComposerEvent { - DraftChanged { content: String }, - ModeChanged { mode: InputMode }, - Submit, - SubmissionHandled { result: SubmissionOutcome }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubmissionOutcome { - MessageSent, - CommandExecuted, - Failed, -} - -#[derive(Debug, Clone)] -pub enum AppEffect { - SetStatus(String), - RequestSubmit, - ResolveToolConsent { - request_id: Uuid, - scope: ConsentScope, - }, -} - -pub fn update(model: &mut AppModel, event: AppEvent) -> Vec { - match event { - AppEvent::Composer(event) => update_composer(&mut model.composer, event), - AppEvent::ToolPermission { request_id, scope } => { - vec![AppEffect::ResolveToolConsent { request_id, scope }] - } - } -} - -fn update_composer(model: &mut ComposerModel, event: ComposerEvent) -> Vec { - match event { - ComposerEvent::DraftChanged { content } => { - model.draft = content; - Vec::new() - } - ComposerEvent::ModeChanged { mode } => { - model.mode = mode; - Vec::new() - } - ComposerEvent::Submit => { - if model.draft.trim().is_empty() { - return vec![AppEffect::SetStatus( - "Cannot send empty message".to_string(), - )]; - } - - model.pending_submit = true; - vec![AppEffect::RequestSubmit] - } - ComposerEvent::SubmissionHandled { result } => { - model.pending_submit = false; - match result { - SubmissionOutcome::MessageSent | SubmissionOutcome::CommandExecuted => { - model.draft.clear(); - if model.mode == InputMode::Editing { - model.mode = InputMode::Normal; - } - } - SubmissionOutcome::Failed => {} - } - Vec::new() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn submit_with_empty_draft_sets_error() { - let mut model = AppModel::default(); - let effects = update(&mut model, AppEvent::Composer(ComposerEvent::Submit)); - - assert!(!model.composer.pending_submit); - assert_eq!(effects.len(), 1); - match &effects[0] { - AppEffect::SetStatus(message) => { - assert!(message.contains("Cannot send empty message")); - } - other => panic!("unexpected effect: {:?}", other), - } - } - - #[test] - fn submit_with_content_requests_processing() { - let mut model = AppModel::default(); - let _ = update( - &mut model, - AppEvent::Composer(ComposerEvent::DraftChanged { - content: "hello world".into(), - }), - ); - - let effects = update(&mut model, AppEvent::Composer(ComposerEvent::Submit)); - - assert!(model.composer.pending_submit); - assert_eq!(effects.len(), 1); - matches!(effects[0], AppEffect::RequestSubmit); - } - - #[test] - fn submission_success_clears_draft_and_mode() { - let mut model = AppModel::default(); - let _ = update( - &mut model, - AppEvent::Composer(ComposerEvent::DraftChanged { - content: "hello world".into(), - }), - ); - let _ = update(&mut model, AppEvent::Composer(ComposerEvent::Submit)); - assert!(model.composer.pending_submit); - - let effects = update( - &mut model, - AppEvent::Composer(ComposerEvent::SubmissionHandled { - result: SubmissionOutcome::MessageSent, - }), - ); - - assert!(effects.is_empty()); - assert!(!model.composer.pending_submit); - assert!(model.composer.draft.is_empty()); - assert_eq!(model.composer.mode, InputMode::Normal); - } -} diff --git a/crates/owlen-tui/src/app/worker.rs b/crates/owlen-tui/src/app/worker.rs deleted file mode 100644 index eb15008..0000000 --- a/crates/owlen-tui/src/app/worker.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use tokio::{sync::mpsc, time}; - -use owlen_core::provider::ProviderManager; - -use super::AppMessage; - -const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(30); - -/// Periodically refresh provider health and emit status updates into the app's -/// message channel. Exits automatically once the receiver side of the channel -/// is dropped. -pub async fn background_worker( - provider_manager: Arc, - message_tx: mpsc::Sender, -) { - let mut interval = time::interval(HEALTH_CHECK_INTERVAL); - let mut last_statuses = provider_manager.provider_statuses().await; - - loop { - interval.tick().await; - - if message_tx.is_closed() { - break; - } - - let statuses = provider_manager.refresh_health().await; - - for (provider_id, status) in statuses { - let changed = match last_statuses.get(&provider_id) { - Some(previous) => previous != &status, - None => true, - }; - - last_statuses.insert(provider_id.clone(), status); - - if changed - && message_tx - .send(AppMessage::ProviderStatus { - provider_id, - status, - }) - .await - .is_err() - { - // Receiver dropped; terminate worker. - return; - } - } - } -} diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs deleted file mode 100644 index 95b7986..0000000 --- a/crates/owlen-tui/src/chat_app.rs +++ /dev/null @@ -1,16018 +0,0 @@ -use anyhow::{Context, Result, anyhow}; -use async_trait::async_trait; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use chrono::{DateTime, Local, Utc}; -use crossterm::{ - event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, - terminal::{disable_raw_mode, enable_raw_mode}, -}; -use futures_util::StreamExt; -use image::{self, GenericImageView, imageops::FilterType}; -use mime_guess; -use owlen_core::Error as CoreError; -use owlen_core::automation::repo::{DiffCaptureMode, RepoAutomation}; -use owlen_core::consent::ConsentScope; -use owlen_core::facade::llm_client::LlmClient; -use owlen_core::mcp::presets::{self, PresetTier}; -use owlen_core::mcp::remote_client::RemoteMcpClient; -use owlen_core::mcp::{McpToolDescriptor, McpToolResponse}; -use owlen_core::provider::{ - AnnotatedModelInfo, ModelInfo as ProviderModelInfo, ProviderErrorKind, ProviderMetadata, - ProviderStatus, ProviderType, -}; -use owlen_core::tools::WEB_SEARCH_TOOL_NAME; -use owlen_core::{ - ProviderConfig, Theme, - config::McpResourceConfig, - conversation::ConversationManager, - model::DetailedModelInfo, - oauth::{DeviceAuthorization, DevicePollState}, - session::{ - CompressionReport, ControllerEvent, SessionController, SessionOutcome, - ToolConsentResolution, - }, - storage::SessionMeta, - types::{ - ChatParameters, ChatResponse, Conversation, MessageAttachment, ModelInfo, Role, TokenUsage, - }, - ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay}, - usage::{UsageBand, UsageSnapshot, UsageWindow, WindowMetrics}, -}; -use owlen_markdown::from_str; -use pathdiff::diff_paths; -use ratatui::{ - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, -}; -use textwrap::{Options, WordSeparator, wrap}; -use tokio::sync::Mutex; -use tokio::{ - sync::mpsc, - task::{self, JoinHandle}, -}; -use tokio_util::sync::CancellationToken; -use tui_textarea::{CursorMove, Input, TextArea}; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; -use uuid::Uuid; - -use crate::app::{ - MessageState, UiRuntime, - mvu::{self, AppEffect, AppEvent, AppModel, ComposerEvent, SubmissionOutcome}, -}; -use crate::commands::{AppCommand, CommandRegistry}; -use crate::config; -use crate::events::Event; -use crate::model_info_panel::ModelInfoPanel; -use crate::slash::{self, McpSlashCommand, SlashCommand}; -use crate::state::{ - CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver, - FileNode, FileTreeState, Keymap, KeymapBindingDescription, KeymapEventResult, KeymapOverrides, - KeymapProfile, KeymapState, ModelPaletteEntry, PaletteSuggestion, PaneDirection, - PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, - SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, - spawn_symbol_search_task, -}; -use crate::toast::{Toast, ToastHistoryEntry, ToastLevel, ToastManager}; -use crate::ui::{format_token_short, format_tool_output}; -use crate::widgets::model_picker::FilterMode; -use crate::{commands, highlight}; -use owlen_core::config::{ - AnimationSettings, GuidanceSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, - LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, LayerSettings, OLLAMA_API_KEY_ENV, - OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, -}; -use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID}; -use owlen_core::{AgentProfile, AgentRegistry}; -// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly -// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`. -use std::collections::hash_map::DefaultHasher; -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; -use std::env; -use std::fs; -use std::sync::Arc; - -use std::fs::OpenOptions; -use std::hash::{Hash, Hasher}; -use std::path::{Component, Path, PathBuf}; -use std::process::Command; -use std::str::FromStr; -use std::time::{Duration, Instant, SystemTime}; - -use dirs::{config_dir, data_local_dir}; -use log::Level; -use serde_json::{Value, json}; - -const HIGH_CONTRAST_THEME_NAME: &str = "grayscale-high-contrast"; - -const ONBOARDING_STATUS_LINE: &str = - "Welcome to Owlen! Press F1 for help or type :tutorial for keybinding tips."; -const ONBOARDING_SYSTEM_STATUS: &str = - "Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/?"; -const TUTORIAL_STATUS: &str = "Tutorial loaded. Review quick tips in the footer."; -const TUTORIAL_SYSTEM_STATUS: &str = - "Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/? • Send ▸ Enter"; -const ONBOARDING_STEP_COUNT: usize = 3; - -const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL; - -const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); -const RESIZE_STEP: f32 = 0.05; -const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; -const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); -pub(crate) const MIN_MESSAGE_CARD_WIDTH: usize = 14; -const MOUSE_SCROLL_STEP: isize = 3; -const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 8_192; -const MAX_QUEUE_ATTEMPTS: u8 = 3; -const THOUGHT_SUMMARY_LIMIT: usize = 5; -const MAX_ATTACHMENT_BYTES: u64 = 8 * 1024 * 1024; -const ATTACHMENT_ASCII_WIDTH: u32 = 24; -const ATTACHMENT_ASCII_HEIGHT: u32 = 12; -const ATTACHMENT_TEXT_PREVIEW_LINES: usize = 12; -const ATTACHMENT_TEXT_PREVIEW_WIDTH: usize = 80; -const ATTACHMENT_INLINE_PREVIEW_LINES: usize = 6; - -#[derive(Clone, Copy, Debug, Default)] -pub struct ContextUsage { - pub prompt_tokens: u32, - pub completion_tokens: u32, - pub context_window: u32, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum AdaptiveLayout { - Compact, - Cozy, - #[default] - Spacious, -} - -impl AdaptiveLayout { - pub fn from_width(width: u16) -> Self { - if width <= 80 { - AdaptiveLayout::Compact - } else if width >= 120 { - AdaptiveLayout::Spacious - } else { - AdaptiveLayout::Cozy - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum GaugeKey { - Context, - UsageHour, - UsageWeek, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum PanePulse { - Stage, - FilePanel, - CodePanel, - ModelPanel, - DebugPanel, -} - -#[derive(Debug, Default, Clone, Copy)] -struct AnimatedGauge { - current: f64, -} - -impl AnimatedGauge { - fn snap(&mut self, value: f64) { - self.current = value.clamp(0.0, 1.0); - } - - fn sample(&mut self, target: f64, smoothing: f64) -> f64 { - let clamped_target = target.clamp(0.0, 1.0); - if (self.current - clamped_target).abs() < 0.0005 { - self.current = clamped_target; - return self.current; - } - let factor = smoothing.clamp(0.01, 1.0); - self.current += (clamped_target - self.current) * factor; - if (self.current - clamped_target).abs() < 0.0005 { - self.current = clamped_target; - } - self.current - } -} - -#[derive(Debug, Default)] -struct GaugeAnimations { - context: AnimatedGauge, - usage_hour: AnimatedGauge, - usage_week: AnimatedGauge, -} - -impl GaugeAnimations { - fn snap(&mut self, key: GaugeKey, value: f64) { - match key { - GaugeKey::Context => self.context.snap(value), - GaugeKey::UsageHour => self.usage_hour.snap(value), - GaugeKey::UsageWeek => self.usage_week.snap(value), - } - } - - fn sample(&mut self, key: GaugeKey, target: f64, smoothing: f64) -> f64 { - match key { - GaugeKey::Context => self.context.sample(target, smoothing), - GaugeKey::UsageHour => self.usage_hour.sample(target, smoothing), - GaugeKey::UsageWeek => self.usage_week.sample(target, smoothing), - } - } -} - -#[derive(Debug, Default, Clone, Copy)] -struct AnimatedPulse { - value: f64, -} - -impl AnimatedPulse { - fn trigger(&mut self) { - self.value = 1.0; - } - - fn clear(&mut self) { - self.value = 0.0; - } - - fn sample(&mut self, decay: f64) -> f64 { - let current = self.value.clamp(0.0, 1.0); - if current == 0.0 { - return 0.0; - } - let decay = decay.clamp(0.1, 0.99); - self.value *= decay; - if self.value < 0.01 { - self.value = 0.0; - } - current - } -} - -#[derive(Debug, Default)] -struct PaneAnimations { - stage: AnimatedPulse, - file: AnimatedPulse, - code: AnimatedPulse, - model: AnimatedPulse, - debug: AnimatedPulse, -} - -impl PaneAnimations { - fn trigger(&mut self, pulse: PanePulse) { - match pulse { - PanePulse::Stage => self.stage.trigger(), - PanePulse::FilePanel => self.file.trigger(), - PanePulse::CodePanel => self.code.trigger(), - PanePulse::ModelPanel => self.model.trigger(), - PanePulse::DebugPanel => self.debug.trigger(), - } - } - - fn sample(&mut self, pulse: PanePulse, decay: f64) -> f64 { - match pulse { - PanePulse::Stage => self.stage.sample(decay), - PanePulse::FilePanel => self.file.sample(decay), - PanePulse::CodePanel => self.code.sample(decay), - PanePulse::ModelPanel => self.model.sample(decay), - PanePulse::DebugPanel => self.debug.sample(decay), - } - } - - fn clear(&mut self, pulse: PanePulse) { - match pulse { - PanePulse::Stage => self.stage.clear(), - PanePulse::FilePanel => self.file.clear(), - PanePulse::CodePanel => self.code.clear(), - PanePulse::ModelPanel => self.model.clear(), - PanePulse::DebugPanel => self.debug.clear(), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum GuidanceOverlay { - CheatSheet, - Onboarding, -} - -#[derive(Clone, Copy, Debug)] -pub(crate) struct LayoutSnapshot { - pub(crate) frame: Rect, - pub(crate) content: Rect, - pub(crate) header_panel: Option, - pub(crate) file_panel: Option, - pub(crate) chat_panel: Option, - pub(crate) thinking_panel: Option, - pub(crate) actions_panel: Option, - pub(crate) attachments_panel: Option, - pub(crate) input_panel: Option, - pub(crate) system_panel: Option, - pub(crate) status_panel: Option, - pub(crate) code_panel: Option, - pub(crate) model_info_panel: Option, - pub(crate) layout_mode: AdaptiveLayout, -} - -impl LayoutSnapshot { - pub(crate) fn new(frame: Rect, content: Rect) -> Self { - Self { - frame, - content, - header_panel: None, - file_panel: None, - chat_panel: None, - thinking_panel: None, - actions_panel: None, - attachments_panel: None, - input_panel: None, - system_panel: None, - status_panel: None, - code_panel: None, - model_info_panel: None, - layout_mode: AdaptiveLayout::default(), - } - } - - fn contains(rect: Rect, column: u16, row: u16) -> bool { - let x_end = rect.x.saturating_add(rect.width); - let y_end = rect.y.saturating_add(rect.height); - column >= rect.x && column < x_end && row >= rect.y && row < y_end - } - - fn region_at(&self, column: u16, row: u16) -> Option { - if let Some(rect) = self.model_info_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::ModelInfo); - } - if let Some(rect) = self.header_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::Header); - } - if let Some(rect) = self.code_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::Code); - } - if let Some(rect) = self.file_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::FileTree); - } - if let Some(rect) = self.input_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::Input); - } - if let Some(rect) = self.system_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::System); - } - if let Some(rect) = self.status_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::Status); - } - if let Some(rect) = self.actions_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::Actions); - } - if let Some(rect) = self.attachments_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::Attachments); - } - if let Some(rect) = self.thinking_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::Thinking); - } - if let Some(rect) = self.chat_panel - && Self::contains(rect, column, row) - { - return Some(UiRegion::Chat); - } - if Self::contains(self.content, column, row) { - Some(UiRegion::Content) - } else if Self::contains(self.frame, column, row) { - Some(UiRegion::Frame) - } else { - None - } - } -} - -impl Default for LayoutSnapshot { - fn default() -> Self { - Self::new(Rect::new(0, 0, 0, 0), Rect::new(0, 0, 0, 0)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UiRegion { - Header, - Frame, - Content, - FileTree, - Chat, - Thinking, - Actions, - Attachments, - Input, - System, - Status, - Code, - ModelInfo, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SlashOutcome { - NotCommand, - Consumed, - Error, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SaveStatus { - Saved, - NoChanges, - Failed, -} - -#[derive(Clone, Debug)] -pub(crate) struct ModelSelectorItem { - kind: ModelSelectorItemKind, -} - -#[derive(Clone, Debug)] -pub(crate) struct HighlightMask { - bits: Vec, -} - -impl HighlightMask { - fn new(bits: Vec) -> Self { - Self { bits } - } - - pub(crate) fn is_marked(&self) -> bool { - self.bits.iter().any(|b| *b) - } - - pub(crate) fn bits(&self) -> &[bool] { - &self.bits - } - - pub(crate) fn truncated(&self, len: usize) -> Self { - let len = len.min(self.bits.len()); - Self::new(self.bits[..len].to_vec()) - } -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct ModelSearchInfo { - pub(crate) score: (usize, usize), - pub(crate) name: Option, - pub(crate) id: Option, - pub(crate) provider: Option, - pub(crate) description: Option, -} - -#[derive(Clone, Debug)] -pub(crate) enum ModelSelectorItemKind { - Header { - provider: String, - expanded: bool, - status: ProviderStatus, - provider_type: ProviderType, - }, - Scope { - provider: String, - label: String, - scope: ModelScope, - status: ModelAvailabilityState, - }, - Model { - provider: String, - model_index: usize, - }, - Empty { - provider: String, - message: Option, - status: Option, - }, -} - -impl ModelSelectorItem { - fn header( - provider: impl Into, - expanded: bool, - status: ProviderStatus, - provider_type: ProviderType, - ) -> Self { - Self { - kind: ModelSelectorItemKind::Header { - provider: provider.into(), - expanded, - status, - provider_type, - }, - } - } - - fn scope( - provider: impl Into, - label: impl Into, - scope: ModelScope, - status: ModelAvailabilityState, - ) -> Self { - Self { - kind: ModelSelectorItemKind::Scope { - provider: provider.into(), - label: label.into(), - scope, - status, - }, - } - } - - fn model(provider: impl Into, model_index: usize) -> Self { - Self { - kind: ModelSelectorItemKind::Model { - provider: provider.into(), - model_index, - }, - } - } - - fn empty( - provider: impl Into, - message: Option, - status: Option, - ) -> Self { - Self { - kind: ModelSelectorItemKind::Empty { - provider: provider.into(), - message, - status, - }, - } - } - - fn is_model(&self) -> bool { - matches!(self.kind, ModelSelectorItemKind::Model { .. }) - } - - fn model_index(&self) -> Option { - match &self.kind { - ModelSelectorItemKind::Model { model_index, .. } => Some(*model_index), - _ => None, - } - } - - fn provider_if_header(&self) -> Option<&str> { - match &self.kind { - ModelSelectorItemKind::Header { provider, .. } - | ModelSelectorItemKind::Scope { provider, .. } => Some(provider), - _ => None, - } - } - - pub(crate) fn kind(&self) -> &ModelSelectorItemKind { - &self.kind - } -} - -fn collect_lower_graphemes(text: &str) -> (Vec<&str>, Vec) { - let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect(); - let lower: Vec = graphemes.iter().map(|g| g.to_lowercase()).collect(); - (graphemes, lower) -} - -fn subsequence_highlight(candidate: &[String], query: &[String]) -> Option> { - if query.is_empty() { - return None; - } - let mut mask = vec![false; candidate.len()]; - let mut q_idx = 0usize; - for (idx, g) in candidate.iter().enumerate() { - if q_idx < query.len() && g == &query[q_idx] { - mask[idx] = true; - q_idx += 1; - } - } - if q_idx == query.len() { - Some(mask) - } else { - None - } -} - -fn search_candidate(candidate: &str, query: &str) -> Option<((usize, usize), HighlightMask)> { - let candidate = candidate.trim(); - let query = query.trim(); - if candidate.is_empty() || query.is_empty() { - return None; - } - - let (original_graphemes, lower_graphemes) = collect_lower_graphemes(candidate); - let candidate_lower = lower_graphemes.join(""); - let query_lower = query.to_lowercase(); - let query_graphemes: Vec = UnicodeSegmentation::graphemes(query_lower.as_str(), true) - .map(|g| g.to_string()) - .collect(); - let query_len = query_graphemes.len(); - - let mut mask = vec![false; original_graphemes.len()]; - - if candidate_lower == query_lower { - mask.fill(true); - return Some(((0, candidate.len()), HighlightMask::new(mask))); - } - - if candidate_lower.starts_with(&query_lower) { - for idx in 0..query_len.min(mask.len()) { - mask[idx] = true; - } - return Some(((1, 0), HighlightMask::new(mask))); - } - - if let Some(start_byte) = candidate_lower.find(&query_lower) { - let mut collected_bytes = 0usize; - let mut start_index = 0usize; - for (idx, grapheme) in lower_graphemes.iter().enumerate() { - if collected_bytes == start_byte { - start_index = idx; - break; - } - collected_bytes += grapheme.len(); - } - for idx in start_index..(start_index + query_len).min(mask.len()) { - mask[idx] = true; - } - return Some(((2, start_byte), HighlightMask::new(mask))); - } - - if let Some(subsequence_mask) = subsequence_highlight(&lower_graphemes, &query_graphemes) - && subsequence_mask.iter().any(|b| *b) - { - return Some(((3, candidate.len()), HighlightMask::new(subsequence_mask))); - } - - None -} - -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub(crate) enum ModelScope { - Local, - Cloud, - Other(String), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub(crate) enum ModelAvailabilityState { - Unknown, - Available, - Unavailable, -} - -impl Default for ModelAvailabilityState { - fn default() -> Self { - Self::Unknown - } -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct ScopeStatusEntry { - pub state: ModelAvailabilityState, - pub message: Option, - pub last_checked_secs: Option, - pub last_success_secs: Option, - pub is_stale: bool, -} - -pub(crate) type ProviderScopeStatus = BTreeMap; - -/// Messages emitted by asynchronous streaming tasks -#[derive(Debug)] -pub enum SessionEvent { - StreamChunk { - message_id: Uuid, - response: ChatResponse, - }, - StreamError { - message_id: Option, - message: String, - error: Option, - }, - ToolExecutionNeeded { - message_id: Uuid, - tool_calls: Vec, - }, - /// Agent iteration update (shows THOUGHT/ACTION/OBSERVATION) - AgentUpdate { content: String }, - /// Agent execution completed with final answer - AgentCompleted { answer: String }, - /// Agent execution failed - AgentFailed { error: String }, - /// Poll the OAuth device authorization flow for the given server - OAuthPoll { - server: String, - authorization: DeviceAuthorization, - }, -} - -pub const HELP_TAB_COUNT: usize = 3; - -pub struct ChatApp { - controller: Arc>, - pub mode: InputMode, - mode_flash_until: Option, - pub status: String, - pub error: Option, - models: Vec, // All models fetched - annotated_models: Vec, // Models annotated with provider metadata - provider_scope_status: HashMap, - pub available_providers: Vec, // Unique providers from models - pub selected_provider: String, // The currently selected provider - pub selected_provider_index: usize, // Index into the available_providers list - pub selected_model_item: Option, // Index into the flattened model selector list - model_selector_items: Vec, // Flattened provider/model list for selector - model_filter_mode: FilterMode, // Active filter applied to the model list - model_filter_memory: FilterMode, // Last user-selected filter mode - model_search_query: String, // Active fuzzy search query for the picker - model_search_hits: HashMap, // Cached search metadata per model index - provider_search_hits: HashMap, // Cached search highlight per provider - visible_model_count: usize, // Number of visible models in current selector view - model_info_panel: ModelInfoPanel, // Dedicated model information viewer - model_details_cache: HashMap, // Cached detailed metadata per model - show_model_info: bool, // Whether the model info panel is visible - model_info_viewport_height: usize, // Cached viewport height for the info panel - expanded_provider: Option, // Which provider group is currently expanded - current_provider: String, // Provider backing the active session - message_line_cache: HashMap, // Cached rendered lines per message - show_cursor_outside_insert: bool, // Configurable cursor visibility flag - syntax_highlighting: bool, // Whether syntax highlighting is enabled - render_markdown: bool, // Whether markdown rendering is enabled - show_message_timestamps: bool, // Whether to render timestamps in chat headers - base_theme_name: String, // Remember the user's preferred theme for accessibility toggles - accessibility_high_contrast: bool, // High-contrast accessibility mode flag - accessibility_reduced_chrome: bool, // Reduced chrome (minimal glass) flag - auto_scroll: AutoScroll, // Auto-scroll state for message rendering - thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel - viewport_height: usize, // Track the height of the messages viewport - thinking_viewport_height: usize, // Track the height of the thinking viewport - content_width: usize, // Track the content width for line wrapping calculations - session_tx: mpsc::UnboundedSender, - streaming: HashSet, - stream_tasks: HashMap>, - textarea: TextArea<'static>, // Advanced text input widget - mvu_model: AppModel, - keymap: Keymap, - current_keymap_profile: KeymapProfile, - keymap_leader: String, - keymap_state: KeymapState, - controller_event_rx: mpsc::UnboundedReceiver, - pending_llm_request: bool, // Flag to indicate LLM request needs to be processed - pending_tool_execution: Option<(Uuid, Vec)>, // Pending tool execution (message_id, tool_calls) - loading_animation_frame: usize, // Frame counter for loading animation - is_loading: bool, // Whether we're currently loading a response - current_thinking: Option, // Current thinking content from last assistant message - // Holds the latest formatted Agentic ReAct actions (thought/action/observation) - agent_actions: Option, - clipboard: String, // Vim-style clipboard for yank/paste - pending_file_action: Option, // Active file action prompt - command_palette: CommandPalette, // Command mode state (buffer + suggestions) - resource_catalog: Vec, // Configured MCP resources for autocompletion - pending_resource_refs: Vec, // Resource references to resolve before send - pending_attachments: Vec, // Attachments staged for the next user turn - attachment_preview_entries: Vec, - attachment_preview_selection: usize, - attachment_preview_source: AttachmentPreviewSource, - oauth_flows: HashMap, // Active OAuth device flows by server - repo_search: RepoSearchState, // Repository search overlay state - repo_search_task: Option>, - repo_search_rx: Option>, - repo_search_file_map: HashMap, - symbol_search: SymbolSearchState, // Symbol search overlay state - symbol_search_task: Option>, - symbol_search_rx: Option>, - visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel - visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels - focused_panel: FocusedPanel, // Currently focused panel for scrolling - chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col) - chat_line_offset: usize, // Number of leading lines trimmed for scrollback - thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col) - code_workspace: CodeWorkspace, // Code views with tabs/splits - last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection - resize_snap_index: usize, // Cycles through 25/50/75 snaps - last_snap_direction: Option, - last_ctrl_c: Option, // Track timing for double Ctrl+C quit - file_tree: FileTreeState, // Workspace file tree state - file_icons: FileIconResolver, // Icon resolver with Nerd/ASCII fallback - file_panel_collapsed: bool, // Whether the file panel is collapsed - file_panel_width: u16, // Cached file panel width - saved_sessions: Vec, // Cached list of saved sessions - selected_session_index: usize, // Index of selected session in browser - help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1)) - theme: Theme, // Current theme - available_themes: Vec, // Cached list of theme names - selected_theme_index: usize, // Index of selected theme in browser - pending_consent: Option, // Pending consent request - queued_consents: VecDeque, // Backlog of consent requests - system_status: String, // System/status messages (tool execution, status, etc) - toasts: ToastManager, - debug_log: DebugLogState, - usage_snapshot: Option, - usage_thresholds: HashMap<(String, UsageWindow), UsageBand>, - context_usage: Option, - last_layout: LayoutSnapshot, - layer_settings: LayerSettings, - animation_settings: AnimationSettings, - active_layout: AdaptiveLayout, - gauge_animations: GaugeAnimations, - pane_animations: PaneAnimations, - guidance_overlay: GuidanceOverlay, - onboarding_step: usize, - guidance_settings: GuidanceSettings, - /// Simple execution budget: maximum number of tool calls allowed per session. - _execution_budget: usize, - /// Agent mode enabled - agent_mode: bool, - /// Agent running flag - agent_running: bool, - /// Loaded agent profiles from configuration - agent_registry: AgentRegistry, - /// Currently selected agent profile identifier - active_agent_id: Option, - /// Operating mode (Chat or Code) - operating_mode: owlen_core::mode::Mode, - /// Flag indicating new messages arrived while scrolled away from tail - new_message_alert: bool, - /// Pending queue of user submissions waiting for execution - command_queue: VecDeque, - /// Whether queued execution is paused - queue_paused: bool, - /// Metadata for the currently running command (if any) - active_command: Option, - /// Rolling buffer of recent thought summaries - thought_summaries: VecDeque, - /// Cached headline summary from the most recent turn - latest_thought_summary: Option, - request_cancellation_token: Option, -} - -#[derive(Clone, Debug)] -pub struct ConsentDialogState { - pub request_id: Uuid, - pub tool_name: String, - pub data_types: Vec, - pub endpoints: Vec, - pub message_id: Uuid, - pub tool_calls: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum QueueSource { - User, - Resume, -} - -impl QueueSource { - fn label(self) -> &'static str { - match self { - QueueSource::User => "user", - QueueSource::Resume => "retry", - } - } -} - -#[derive(Debug, Clone)] -struct QueuedCommand { - content: String, - enqueued_at: DateTime, - source: QueueSource, - attempts: u8, - attachments: Vec, -} - -impl QueuedCommand { - fn new(content: String, attachments: Vec, source: QueueSource) -> Self { - Self { - content, - enqueued_at: Utc::now(), - source, - attempts: 0, - attachments, - } - } - - fn from_active(active: ActiveCommand) -> Option { - if active.attempts + 1 >= MAX_QUEUE_ATTEMPTS { - return None; - } - Some(Self { - content: active.content, - enqueued_at: active.enqueued_at, - source: QueueSource::Resume, - attempts: active.attempts + 1, - attachments: active.attachments, - }) - } -} - -#[derive(Debug)] -struct ActiveCommand { - response_id: Option, - content: String, - source: QueueSource, - attempts: u8, - enqueued_at: DateTime, - attachments: Vec, -} - -impl ActiveCommand { - fn new( - content: String, - attachments: Vec, - source: QueueSource, - attempts: u8, - enqueued_at: DateTime, - ) -> Self { - Self { - response_id: None, - content, - source, - attempts, - enqueued_at, - attachments, - } - } - - fn record_response(&mut self, response_id: Uuid) { - self.response_id = Some(response_id); - } -} - -#[derive(Clone)] -struct MessageCacheEntry { - theme_name: String, - wrap_width: usize, - role_label_mode: RoleLabelDisplay, - syntax_highlighting: bool, - render_markdown: bool, - show_timestamps: bool, - content_hash: u64, - lines: Vec>, - metrics: MessageLayoutMetrics, -} - -#[derive(Clone, Debug, Default)] -struct MessageLayoutMetrics { - line_count: usize, - body_width: usize, - card_width: usize, -} - -pub(crate) struct MessageRenderContext<'a> { - formatter: &'a mut owlen_core::formatting::MessageFormatter, - role_label_mode: RoleLabelDisplay, - body_width: usize, - card_width: usize, - is_streaming: bool, - loading_indicator: &'a str, - theme: &'a Theme, - syntax_highlighting: bool, - render_markdown: bool, -} - -impl<'a> MessageRenderContext<'a> { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - formatter: &'a mut owlen_core::formatting::MessageFormatter, - role_label_mode: RoleLabelDisplay, - body_width: usize, - card_width: usize, - is_streaming: bool, - loading_indicator: &'a str, - theme: &'a Theme, - syntax_highlighting: bool, - render_markdown: bool, - ) -> Self { - Self { - formatter, - role_label_mode, - body_width, - card_width, - is_streaming, - loading_indicator, - theme, - syntax_highlighting, - render_markdown, - } - } -} - -#[derive(Debug, Clone)] -enum MessageSegment { - Text { - lines: Vec, - }, - CodeBlock { - language: Option, - lines: Vec, - }, -} - -#[derive(Debug, Clone)] -pub(crate) struct AttachmentPreviewEntry { - pub(crate) summary: String, - pub(crate) preview_lines: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum AttachmentPreviewSource { - None, - Pending, - Message(Uuid), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum FileOpenDisposition { - Primary, - SplitHorizontal, - SplitVertical, - Tab, -} - -#[derive(Debug, Clone)] -struct FileActionPrompt { - kind: FileActionKind, - buffer: String, -} - -#[derive(Debug, Clone)] -enum FileActionKind { - CreateFile { base: PathBuf }, - CreateFolder { base: PathBuf }, - Rename { original: PathBuf }, - Move { original: PathBuf }, - Delete { target: PathBuf, confirm: String }, -} - -impl FileActionPrompt { - fn new(kind: FileActionKind, initial: impl Into) -> Self { - Self { - kind, - buffer: initial.into(), - } - } - - fn push_char(&mut self, ch: char) { - self.buffer.push(ch); - } - - fn pop_char(&mut self) { - self.buffer.pop(); - } - - fn set_buffer(&mut self, buffer: impl Into) { - self.buffer = buffer.into(); - } - - fn is_destructive(&self) -> bool { - matches!(self.kind, FileActionKind::Delete { .. }) - } -} - -impl ChatApp { - pub async fn new( - controller: SessionController, - controller_event_rx: mpsc::UnboundedReceiver, - ) -> Result<(Self, mpsc::UnboundedReceiver)> { - let (session_tx, session_rx) = mpsc::unbounded_channel(); - let mut textarea = TextArea::default(); - configure_textarea_defaults(&mut textarea); - - // Load theme and provider based on config before moving `controller`. - let config_guard = controller.config_async().await; - let theme_name = config_guard.ui.theme.clone(); - let current_provider = Self::canonical_provider_id(&config_guard.general.default_provider); - let show_onboarding = config_guard.ui.show_onboarding; - let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert; - let syntax_highlighting = config_guard.ui.syntax_highlighting; - let render_markdown = config_guard.ui.render_markdown; - let show_timestamps = config_guard.ui.show_timestamps; - let icon_mode = config_guard.ui.icon_mode; - let keymap_path = config_guard.ui.keymap_path.clone(); - let keymap_profile = config_guard.ui.keymap_profile.clone(); - let keymap_leader_raw = config_guard.ui.keymap_leader.clone(); - let accessibility = config_guard.ui.accessibility.clone(); - let layer_settings = config_guard.ui.layers.clone(); - let animation_settings = config_guard.ui.animations.clone(); - let guidance_settings = config_guard.ui.guidance.clone(); - drop(config_guard); - let keymap_overrides = KeymapOverrides::new(keymap_leader_raw); - let keymap = { - let registry = CommandRegistry::default(); - Keymap::load( - keymap_path.as_deref(), - keymap_profile.as_deref(), - ®istry, - keymap_overrides.clone(), - ) - }; - let current_keymap_profile = keymap.profile(); - let keymap_leader = keymap_overrides.leader().to_string(); - let base_theme_name = theme_name.clone(); - let mut theme = owlen_core::get_theme(&base_theme_name).unwrap_or_else(|| { - eprintln!("Warning: Theme '{}' not found, using default", theme_name); - Theme::default() - }); - let accessibility_high_contrast = accessibility.high_contrast; - let accessibility_reduced_chrome = accessibility.reduced_chrome; - if accessibility_high_contrast { - theme = owlen_core::get_theme(HIGH_CONTRAST_THEME_NAME).unwrap_or_else(|| { - eprintln!( - "Warning: High-contrast theme '{}' not found, using default", - HIGH_CONTRAST_THEME_NAME - ); - Theme::default() - }); - } - - let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let file_tree = FileTreeState::new(workspace_root.clone()); - let file_icons = FileIconResolver::from_mode(icon_mode); - - install_global_logger(); - - let agent_registry = AgentRegistry::discover(Some(&workspace_root)).unwrap_or_else(|err| { - eprintln!( - "Warning: failed to load agent configurations from {}: {err}", - workspace_root.display() - ); - AgentRegistry::default() - }); - - let mut app = Self { - controller: Arc::new(Mutex::new(controller)), - mode: if show_onboarding { - InputMode::Help - } else { - InputMode::Normal - }, - mode_flash_until: None, - status: if show_onboarding { - ONBOARDING_STATUS_LINE.to_string() - } else { - "Normal mode • Press F1 for help".to_string() - }, - error: None, - models: Vec::new(), - annotated_models: Vec::new(), - provider_scope_status: HashMap::new(), - available_providers: Vec::new(), - selected_provider: current_provider.clone(), - selected_provider_index: 0, - selected_model_item: None, - model_selector_items: Vec::new(), - model_filter_mode: FilterMode::All, - model_filter_memory: FilterMode::All, - model_search_query: String::new(), - model_search_hits: HashMap::new(), - provider_search_hits: HashMap::new(), - visible_model_count: 0, - model_info_panel: ModelInfoPanel::new(), - model_details_cache: HashMap::new(), - show_model_info: false, - model_info_viewport_height: 0, - expanded_provider: None, - current_provider, - message_line_cache: HashMap::new(), - auto_scroll: AutoScroll::default(), - thinking_scroll: AutoScroll::default(), - viewport_height: 10, // Default viewport height, will be updated during rendering - thinking_viewport_height: 4, // Default thinking viewport height - content_width: 80, // Default content width, will be updated during rendering - session_tx, - streaming: std::collections::HashSet::new(), - stream_tasks: HashMap::new(), - textarea, - mvu_model: AppModel::default(), - keymap, - current_keymap_profile, - keymap_leader, - keymap_state: KeymapState::default(), - controller_event_rx, - pending_llm_request: false, - pending_tool_execution: None, - loading_animation_frame: 0, - is_loading: false, - current_thinking: None, - agent_actions: None, - clipboard: String::new(), - pending_file_action: None, - command_palette: CommandPalette::new(), - resource_catalog: Vec::new(), - pending_resource_refs: Vec::new(), - pending_attachments: Vec::new(), - attachment_preview_entries: Vec::new(), - attachment_preview_selection: 0, - attachment_preview_source: AttachmentPreviewSource::None, - oauth_flows: HashMap::new(), - repo_search: RepoSearchState::new(), - repo_search_task: None, - repo_search_rx: None, - repo_search_file_map: HashMap::new(), - symbol_search: SymbolSearchState::new(), - symbol_search_task: None, - symbol_search_rx: None, - visual_start: None, - visual_end: None, - focused_panel: FocusedPanel::Input, - chat_cursor: (0, 0), - chat_line_offset: 0, - thinking_cursor: (0, 0), - code_workspace: CodeWorkspace::new(), - last_resize_tap: None, - resize_snap_index: 0, - last_snap_direction: None, - last_ctrl_c: None, - file_tree, - file_icons, - file_panel_collapsed: true, - file_panel_width: 32, - saved_sessions: Vec::new(), - selected_session_index: 0, - help_tab_index: 0, - theme, - available_themes: Vec::new(), - selected_theme_index: 0, - pending_consent: None, - queued_consents: VecDeque::new(), - system_status: if show_onboarding { - ONBOARDING_SYSTEM_STATUS.to_string() - } else { - String::new() - }, - toasts: ToastManager::new(), - debug_log: DebugLogState::new(), - usage_snapshot: None, - usage_thresholds: HashMap::new(), - context_usage: None, - last_layout: LayoutSnapshot::default(), - _execution_budget: 50, - agent_mode: false, - agent_running: false, - agent_registry, - active_agent_id: None, - operating_mode: owlen_core::mode::Mode::default(), - new_message_alert: false, - show_cursor_outside_insert, - syntax_highlighting, - render_markdown, - show_message_timestamps: show_timestamps, - base_theme_name, - accessibility_high_contrast, - accessibility_reduced_chrome, - layer_settings, - animation_settings, - active_layout: AdaptiveLayout::default(), - gauge_animations: GaugeAnimations::default(), - pane_animations: PaneAnimations::default(), - guidance_overlay: if show_onboarding { - GuidanceOverlay::Onboarding - } else { - GuidanceOverlay::CheatSheet - }, - onboarding_step: 0, - guidance_settings, - command_queue: VecDeque::new(), - queue_paused: false, - active_command: None, - thought_summaries: VecDeque::new(), - latest_thought_summary: None, - request_cancellation_token: None, - }; - - app.mvu_model.composer.mode = InputMode::Normal; - app.mvu_model.composer.draft = { - let controller = app.controller_lock(); - controller.input_buffer().text().to_string() - }; - - app.append_system_status(&format!( - "Icons: {} ({})", - app.file_icons.status_label(), - app.file_icons.detection_label() - )); - - app.update_command_palette_catalog(); - app.refresh_resource_catalog().await?; - app.refresh_mcp_slash_commands().await?; - - if let Err(err) = app.restore_workspace_layout().await { - eprintln!("Warning: failed to restore workspace layout: {err}"); - } - - app.refresh_usage_summary().await?; - - Ok((app, session_rx)) - } - - /// Check if consent dialog is currently shown - pub fn has_pending_consent(&self) -> bool { - self.pending_consent.is_some() - } - - /// Returns a locked synchronous guard for the `SessionController`. - /// Uses try_lock() with a brief spin to avoid block_in_place while still - /// providing synchronous access from non-async rendering code. - fn controller_lock(&self) -> tokio::sync::MutexGuard<'_, SessionController> { - // Try to acquire the lock with a small number of retries. - // Controller locks are typically held very briefly, so this should succeed quickly. - for _ in 0..100 { - if let Ok(guard) = self.controller.try_lock() { - return guard; - } - std::thread::yield_now(); - } - // If we still can't get the lock, panic as this indicates a deadlock or - // the lock is being held for too long (which would be a bug). - panic!("Failed to acquire controller lock after retries - possible deadlock"); - } - - /// Returns a locked asynchronous guard for the `SessionController`. - async fn controller_lock_async(&self) -> tokio::sync::MutexGuard<'_, SessionController> { - self.controller.lock().await - } - - pub fn with_config(&self, f: impl FnOnce(&owlen_core::config::Config) -> R) -> R { - let controller = self.controller_lock(); - let guard = controller.config(); - f(&guard) - } - - pub fn with_config_mut(&self, f: impl FnOnce(&mut owlen_core::config::Config) -> R) -> R { - let controller = self.controller_lock(); - let mut guard = controller.config_mut(); - f(&mut guard) - } - - /// Get the current consent dialog state - pub fn consent_dialog(&self) -> Option<&ConsentDialogState> { - self.pending_consent.as_ref() - } - - fn enqueue_consent_request(&mut self, consent: ConsentDialogState) { - if self.pending_consent.is_none() { - self.pending_consent = Some(consent); - } else { - self.queued_consents.push_back(consent); - } - } - - fn advance_consent_queue(&mut self) { - if self.pending_consent.is_some() { - return; - } - if let Some(next) = self.queued_consents.pop_front() { - self.pending_consent = Some(next); - } - } - - fn handle_controller_event(&mut self, event: ControllerEvent) -> Result<()> { - match event { - ControllerEvent::ToolRequested { - request_id, - message_id, - tool_name, - data_types, - endpoints, - tool_calls, - } => { - self.enqueue_consent_request(ConsentDialogState { - request_id, - message_id, - tool_name, - data_types, - endpoints, - tool_calls, - }); - } - ControllerEvent::CompressionCompleted { report } => { - self.handle_compression_report(&report); - } - } - Ok(()) - } - - fn handle_compression_report(&mut self, report: &CompressionReport) { - let saved_tokens = report - .estimated_tokens_before - .saturating_sub(report.estimated_tokens_after); - let saved_fmt = format_token_short(saved_tokens as u64); - let before_fmt = format_token_short(report.estimated_tokens_before as u64); - let after_fmt = format_token_short(report.estimated_tokens_after as u64); - let mode_label = if report.automated { "Auto" } else { "Manual" }; - - self.status = format!( - "{mode_label} compression archived {} messages ({} → {}, saved {}).", - report.compressed_messages, before_fmt, after_fmt, saved_fmt - ); - self.error = None; - let toast_level = if report.automated { - ToastLevel::Info - } else { - ToastLevel::Success - }; - self.push_toast( - toast_level, - format!( - "{mode_label} compression saved {saved_fmt} tokens ({} messages).", - report.compressed_messages - ), - ); - } - - fn apply_tool_consent_resolution(&mut self, resolution: ToolConsentResolution) -> Result<()> { - let ToolConsentResolution { - message_id, - tool_name, - scope, - tool_calls, - .. - } = resolution; - - match scope { - ConsentScope::Denied => { - self.pending_tool_execution = None; - self.status = format!("✗ Consent denied for {}", tool_name); - self.set_system_status(format!("✗ Consent denied: {}", tool_name)); - self.error = Some(format!("Tool {} was blocked by user", tool_name)); - - self.push_assistant_message(format!( - "I could not execute `{tool_name}` because consent was denied. \ - Replying without running the tool." - )); - self.notify_new_activity(); - } - ConsentScope::Once | ConsentScope::Session | ConsentScope::Permanent => { - let scope_label = match scope { - ConsentScope::Once => "once", - ConsentScope::Session => "session", - ConsentScope::Permanent => "permanent", - ConsentScope::Denied => unreachable!("handled above"), - }; - self.status = format!("✓ Consent granted ({scope_label}) for {}", tool_name); - self.set_system_status(format!("✓ Consent granted ({scope_label}): {}", tool_name)); - self.error = None; - self.pending_tool_execution = Some((message_id, tool_calls)); - } - } - - self.pending_consent = None; - self.advance_consent_queue(); - Ok(()) - } - - pub fn status_message(&self) -> &str { - &self.status - } - - pub fn error_message(&self) -> Option<&String> { - self.error.as_ref() - } - - pub fn mode(&self) -> InputMode { - self.mode - } - - pub fn conversation(&self) -> Conversation { - let controller = self.controller_lock(); - controller.conversation().clone() - } - - pub fn selected_model(&self) -> String { - let controller = self.controller_lock(); - controller.selected_model().to_string() - } - - pub fn current_provider(&self) -> &str { - &self.current_provider - } - - pub fn usage_snapshot(&self) -> Option<&UsageSnapshot> { - self.usage_snapshot.as_ref() - } - - pub fn context_usage_with_fallback(&self) -> Option { - if let Some(usage) = self.context_usage { - Some(usage) - } else { - self.active_context_window().map(|window| ContextUsage { - prompt_tokens: 0, - completion_tokens: 0, - context_window: window, - }) - } - } - - pub(crate) fn guidance_overlay(&self) -> GuidanceOverlay { - self.guidance_overlay - } - - pub fn onboarding_step(&self) -> usize { - self.onboarding_step - } - - pub fn onboarding_step_count(&self) -> usize { - ONBOARDING_STEP_COUNT - } - - pub fn coach_marks_complete(&self) -> bool { - self.guidance_settings.coach_marks_complete - } - - pub fn keymap_bindings(&self) -> Vec { - self.keymap.describe_bindings() - } - - fn update_context_usage(&mut self, usage: &TokenUsage) { - let context_window = self - .active_context_window() - .unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS); - self.context_usage = Some(ContextUsage { - prompt_tokens: usage.prompt_tokens, - completion_tokens: usage.completion_tokens, - context_window, - }); - } - - fn active_context_window(&self) -> Option { - let current_model = self.selected_model(); - - self.models.iter().find_map(|model| { - if model.id == current_model || model.name == current_model { - model.context_window - } else { - None - } - }) - } - - pub fn should_show_code_view(&self) -> bool { - if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { - return false; - } - if let Some(pane) = self.code_workspace.active_pane() { - return pane.display_path().is_some() || !pane.lines.is_empty(); - } - false - } - - pub fn code_view_path(&self) -> Option<&str> { - self.code_workspace - .active_pane() - .and_then(|pane| pane.display_path()) - } - - pub fn is_code_mode(&self) -> bool { - matches!(self.operating_mode, owlen_core::mode::Mode::Code) - } - - pub fn code_view_lines(&self) -> &[String] { - self.code_workspace - .active_pane() - .map(|pane| pane.lines.as_slice()) - .unwrap_or(&[]) - } - - pub fn code_view_scroll(&self) -> Option<&AutoScroll> { - self.code_workspace.active_pane().map(|pane| &pane.scroll) - } - - pub fn code_view_scroll_mut(&mut self) -> Option<&mut AutoScroll> { - self.code_workspace - .active_tab_mut() - .and_then(|tab| tab.active_pane_mut()) - .map(|pane| &mut pane.scroll) - } - - pub fn set_code_view_viewport_height(&mut self, height: usize) { - self.code_workspace.set_active_viewport_height(height); - } - - pub fn repo_search(&self) -> &RepoSearchState { - &self.repo_search - } - - pub fn repo_search_mut(&mut self) -> &mut RepoSearchState { - &mut self.repo_search - } - - pub fn symbol_search(&self) -> &SymbolSearchState { - &self.symbol_search - } - - pub fn symbol_search_mut(&mut self) -> &mut SymbolSearchState { - &mut self.symbol_search - } - - fn repo_search_display_path(&self, absolute: &Path) -> String { - if let Some(relative) = diff_paths(absolute, self.file_tree().root()) { - if relative.as_os_str().is_empty() { - ".".to_string() - } else { - relative.to_string_lossy().into_owned() - } - } else { - absolute.to_string_lossy().into_owned() - } - } - - fn ensure_repo_search_file_index(&mut self, path: &Path) -> usize { - if let Some(index) = self.repo_search_file_map.get(path).copied() { - return index; - } - let display = self.repo_search_display_path(path); - let idx = self - .repo_search - .ensure_file_entry(path.to_path_buf(), display); - self.repo_search_file_map.insert(path.to_path_buf(), idx); - idx - } - - fn cancel_repo_search_process(&mut self) { - if let Some(handle) = self.repo_search_task.take() { - handle.abort(); - } - self.repo_search_rx = None; - } - - fn poll_repo_search(&mut self) { - if let Some(mut rx) = self.repo_search_rx.take() { - use tokio::sync::mpsc::error::TryRecvError; - let mut keep_receiver = true; - loop { - match rx.try_recv() { - Ok(message) => { - if !self.handle_repo_search_message(message) { - keep_receiver = false; - break; - } - } - Err(TryRecvError::Empty) => break, - Err(TryRecvError::Disconnected) => { - self.repo_search_task = None; - keep_receiver = false; - break; - } - } - } - if keep_receiver { - self.repo_search_rx = Some(rx); - } - } - } - - fn handle_repo_search_message(&mut self, message: RepoSearchMessage) -> bool { - match message { - RepoSearchMessage::File { path } => { - self.ensure_repo_search_file_index(&path); - true - } - RepoSearchMessage::Match { - path, - line_number, - column, - preview, - matched, - } => { - let idx = self.ensure_repo_search_file_index(&path); - self.repo_search - .add_match(idx, line_number, column, preview, matched); - true - } - RepoSearchMessage::Done { matches } => { - self.repo_search.finish(matches); - self.repo_search_task = None; - self.repo_search_rx = None; - self.status = if matches == 0 { - "Repo search: no matches".to_string() - } else { - format!("Repo search: {matches} match(es)") - }; - false - } - RepoSearchMessage::Error(err) => { - self.repo_search.mark_error(err.clone()); - self.repo_search_task = None; - self.repo_search_rx = None; - self.error = Some(err.clone()); - self.status = format!("Repo search failed: {err}"); - false - } - } - } - - async fn start_repo_search(&mut self) -> Result<()> { - let Some(query) = self.repo_search.prepare_run() else { - if self.repo_search.query_input().is_empty() { - self.status = "Enter a search query".to_string(); - } - return Ok(()); - }; - - self.cancel_repo_search_process(); - self.repo_search_file_map.clear(); - let root = self.file_tree().root().to_path_buf(); - - match spawn_repo_search_task(root, query.clone()) { - Ok((handle, rx)) => { - self.repo_search_task = Some(handle); - self.repo_search_rx = Some(rx); - self.status = format!("Searching for \"{query}\"…"); - self.error = None; - } - Err(err) => { - let message = err.to_string(); - self.repo_search.mark_error(message.clone()); - self.error = Some(message.clone()); - self.status = format!("Failed to start search: {message}"); - } - } - - Ok(()) - } - - async fn open_repo_search_match(&mut self) -> Result<()> { - let Some((file_index, match_index)) = self.repo_search.selected_indices() else { - self.status = "Select a match to open".to_string(); - return Ok(()); - }; - - if !self.is_code_mode() { - self.status = "Switch to code mode to open repository matches".to_string(); - self.error = None; - return Ok(()); - } - - let (absolute, display, line_number, column) = { - let file = &self.repo_search.files()[file_index]; - let m = &file.matches[match_index]; - ( - file.absolute.clone(), - file.display.clone(), - m.line_number, - m.column, - ) - }; - - let root = self.file_tree().root().to_path_buf(); - let request_path = if absolute.starts_with(&root) { - diff_paths(&absolute, &root) - .filter(|rel| !rel.as_os_str().is_empty()) - .map(|rel| rel.to_string_lossy().into_owned()) - .unwrap_or_else(|| absolute.to_string_lossy().into_owned()) - } else { - absolute.to_string_lossy().into_owned() - }; - - if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { - self.set_mode(owlen_core::mode::Mode::Code).await; - } - - let file_content = { - let controller = self.controller_lock_async().await; - controller.read_file_with_tools(&request_path).await - }; - - match file_content { - Ok(content) => { - self.prepare_code_view_target(FileOpenDisposition::Primary); - self.set_code_view_content(display.clone(), Some(absolute.clone()), content); - if let Some(pane) = self.code_workspace.active_pane_mut() { - pane.scroll.stick_to_bottom = false; - let target_line = line_number.saturating_sub(1) as usize; - let viewport = pane.viewport_height.max(1); - let scroll = target_line.saturating_sub(viewport / 2); - pane.scroll.scroll = scroll; - } - self.file_tree_mut().reveal(&absolute); - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - self.set_input_mode(InputMode::Normal); - self.status = format!("Opened {}:{}:{column}", display, line_number); - self.error = None; - } - Err(err) => { - let message = format!("Failed to open {}: {}", display, err); - self.error = Some(message.clone()); - self.status = message; - } - } - - Ok(()) - } - - async fn open_repo_search_scratch(&mut self) -> Result<()> { - if !self.repo_search.has_results() { - self.status = "No matches to open".to_string(); - return Ok(()); - } - - if !self.is_code_mode() { - self.status = "Switch to code mode to open repository matches".to_string(); - self.error = None; - return Ok(()); - } - - let mut buffer = String::new(); - for file in self.repo_search.files() { - if file.matches.is_empty() { - continue; - } - buffer.push_str(&format!("{}\n", file.display)); - for m in &file.matches { - buffer.push_str(&format!( - " {:>6}:{:<3} {}\n", - m.line_number, m.column, m.preview - )); - } - buffer.push('\n'); - } - - let title = if let Some(query) = self.repo_search.last_query() { - format!("Search results: {query}") - } else { - "Search results".to_string() - }; - - if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { - self.set_mode(owlen_core::mode::Mode::Code).await; - } - - self.code_workspace.open_new_tab(); - self.set_code_view_content(title.clone(), None::, buffer); - if let Some(pane) = self.code_workspace.active_pane_mut() { - pane.is_dirty = false; - pane.is_staged = false; - } - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - self.set_input_mode(InputMode::Normal); - self.status = format!("Opened scratch buffer for {title}"); - Ok(()) - } - - fn cancel_symbol_search_process(&mut self) { - if let Some(handle) = self.symbol_search_task.take() { - handle.abort(); - } - self.symbol_search_rx = None; - } - - fn poll_symbol_search(&mut self) { - if let Some(mut rx) = self.symbol_search_rx.take() { - use tokio::sync::mpsc::error::TryRecvError; - let mut keep_receiver = true; - loop { - match rx.try_recv() { - Ok(message) => { - if !self.handle_symbol_search_message(message) { - keep_receiver = false; - break; - } - } - Err(TryRecvError::Empty) => break, - Err(TryRecvError::Disconnected) => { - self.symbol_search_task = None; - keep_receiver = false; - break; - } - } - } - if keep_receiver { - self.symbol_search_rx = Some(rx); - } - } - } - - fn handle_symbol_search_message(&mut self, message: SymbolSearchMessage) -> bool { - match message { - SymbolSearchMessage::Symbols(batch) => { - self.symbol_search.add_symbols(batch); - true - } - SymbolSearchMessage::Done => { - self.symbol_search.finish(); - self.symbol_search_task = None; - self.symbol_search_rx = None; - self.status = "Symbol index ready".to_string(); - false - } - SymbolSearchMessage::Error(err) => { - self.symbol_search.mark_error(err.clone()); - self.symbol_search_task = None; - self.symbol_search_rx = None; - self.error = Some(err.clone()); - self.status = format!("Symbol search failed: {err}"); - false - } - } - } - - async fn start_symbol_search(&mut self) -> Result<()> { - self.cancel_symbol_search_process(); - self.symbol_search.begin_index(); - let root = self.file_tree().root().to_path_buf(); - match spawn_symbol_search_task(root) { - Ok((handle, rx)) => { - self.symbol_search_task = Some(handle); - self.symbol_search_rx = Some(rx); - self.status = "Indexing symbols…".to_string(); - self.error = None; - } - Err(err) => { - let message = err.to_string(); - self.symbol_search.mark_error(message.clone()); - self.error = Some(message.clone()); - self.status = format!("Unable to start symbol search: {message}"); - } - } - Ok(()) - } - - async fn open_symbol_search_entry(&mut self) -> Result<()> { - let Some(entry) = self.symbol_search.selected_entry().cloned() else { - self.status = "Select a symbol".to_string(); - return Ok(()); - }; - - let root = self.file_tree().root().to_path_buf(); - let request_path = if entry.file.starts_with(&root) { - diff_paths(&entry.file, &root) - .filter(|rel| !rel.as_os_str().is_empty()) - .map(|rel| rel.to_string_lossy().into_owned()) - .unwrap_or_else(|| entry.file.to_string_lossy().into_owned()) - } else { - entry.file.to_string_lossy().into_owned() - }; - - let file_content = { - let controller = self.controller_lock_async().await; - controller.read_file_with_tools(&request_path).await - }; - - match file_content { - Ok(content) => { - self.prepare_code_view_target(FileOpenDisposition::Primary); - self.set_code_view_content( - entry.display_path.clone(), - Some(entry.file.clone()), - content, - ); - if let Some(pane) = self.code_workspace.active_pane_mut() { - pane.scroll.stick_to_bottom = false; - let target_line = entry.line.saturating_sub(1) as usize; - let viewport = pane.viewport_height.max(1); - let scroll = target_line.saturating_sub(viewport / 2); - pane.scroll.scroll = scroll; - } - self.file_tree_mut().reveal(&entry.file); - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - self.set_input_mode(InputMode::Normal); - self.status = format!( - "Jumped to {} {}:{}", - entry.kind.label(), - entry.display_path, - entry.line - ); - self.error = None; - } - Err(err) => { - let message = format!("Failed to open {}: {}", entry.display_path, err); - self.error = Some(message.clone()); - self.status = message; - } - } - - Ok(()) - } - - pub fn code_view_viewport_height(&self) -> usize { - self.code_workspace - .active_pane() - .map(|pane| pane.viewport_height) - .unwrap_or(0) - } - - pub fn has_loaded_code_view(&self) -> bool { - self.code_workspace - .active_pane() - .map(|pane| pane.display_path().is_some() || !pane.lines.is_empty()) - .unwrap_or(false) - } - - pub fn file_tree(&self) -> &FileTreeState { - &self.file_tree - } - - pub fn file_tree_mut(&mut self) -> &mut FileTreeState { - &mut self.file_tree - } - - pub fn file_icons(&self) -> &FileIconResolver { - &self.file_icons - } - - pub fn workspace(&self) -> &CodeWorkspace { - &self.code_workspace - } - - pub fn workspace_mut(&mut self) -> &mut CodeWorkspace { - &mut self.code_workspace - } - - pub fn is_file_panel_collapsed(&self) -> bool { - self.file_panel_collapsed - } - - pub fn set_file_panel_collapsed(&mut self, collapsed: bool) { - if self.file_panel_collapsed != collapsed { - self.trigger_pane_pulse(PanePulse::FilePanel); - } - self.file_panel_collapsed = collapsed; - } - - pub fn file_panel_width(&self) -> u16 { - self.file_panel_width - } - - pub fn set_file_panel_width(&mut self, width: u16) -> u16 { - const MIN_WIDTH: u16 = 24; - const MAX_WIDTH: u16 = 80; - let clamped = width.clamp(MIN_WIDTH, MAX_WIDTH); - self.file_panel_width = clamped; - clamped - } - - pub fn expand_file_panel(&mut self) { - if !self.is_code_mode() { - self.status = "Switch to code mode to use the file explorer".to_string(); - self.error = None; - return; - } - if self.file_panel_collapsed { - self.set_file_panel_collapsed(false); - self.focused_panel = FocusedPanel::Files; - self.ensure_focus_valid(); - } - } - - pub fn collapse_file_panel(&mut self) { - if !self.file_panel_collapsed { - self.set_file_panel_collapsed(true); - if matches!(self.focused_panel, FocusedPanel::Files) { - self.focused_panel = FocusedPanel::Chat; - } - self.ensure_focus_valid(); - } - } - - pub fn toggle_file_panel(&mut self) { - if !self.is_code_mode() { - self.status = "File explorer is available in code mode".to_string(); - self.error = None; - return; - } - if self.file_panel_collapsed { - self.expand_file_panel(); - } else { - self.collapse_file_panel(); - } - } - - // Synchronous access for UI rendering and other callers that expect a Config snapshot. - pub fn config(&self) -> owlen_core::config::Config { - self.with_config(|cfg| cfg.clone()) - } - - // Asynchronous version retained for places that already await the config snapshot. - pub async fn config_async(&self) -> owlen_core::config::Config { - let controller = self.controller_lock_async().await; - let guard = controller.config_async().await; - guard.clone() - } - - /// Get the current operating mode - pub fn get_mode(&self) -> owlen_core::mode::Mode { - self.operating_mode - } - - /// Set the operating mode - pub async fn set_mode(&mut self, mode: owlen_core::mode::Mode) { - let result = { - let mut controller = self.controller_lock_async().await; - controller.set_operating_mode(mode).await - }; - - if let Err(err) = result { - self.error = Some(format!("Failed to switch mode: {}", err)); - return; - } - - if !matches!(mode, owlen_core::mode::Mode::Code) { - self.collapse_file_panel(); - self.close_code_view(); - self.set_system_status(String::new()); - } - - self.operating_mode = mode; - self.status = format!("Switched to {} mode", mode); - self.error = None; - } - - async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> { - { - let mut controller = self.controller_lock_async().await; - controller - .set_tool_enabled(WEB_SEARCH_TOOL_NAME, enabled) - .await - .map_err(|err| anyhow!(err))?; - } - self.with_config(config::save_config)?; - self.refresh_usage_summary().await?; - Ok(()) - } - - /// Override the status line with a custom message. - pub fn set_status_message>(&mut self, status: S) { - self.status = status.into(); - } - - pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] { - &self.model_selector_items - } - - pub(crate) fn annotated_models(&self) -> &[AnnotatedModelInfo] { - &self.annotated_models - } - - pub(crate) fn model_filter_mode(&self) -> FilterMode { - self.model_filter_mode - } - - pub(crate) fn model_search_query(&self) -> &str { - &self.model_search_query - } - - pub(crate) fn model_search_info(&self, index: usize) -> Option<&ModelSearchInfo> { - self.model_search_hits.get(&index) - } - - pub(crate) fn provider_search_highlight(&self, provider: &str) -> Option<&HighlightMask> { - self.provider_search_hits.get(provider) - } - - pub(crate) fn visible_model_count(&self) -> usize { - self.visible_model_count - } - - fn update_model_filter_mode(&mut self, mode: FilterMode) { - if self.model_filter_mode != mode { - self.model_filter_mode = mode; - self.model_filter_memory = mode; - self.rebuild_model_selector_items(); - } else if !self.model_search_query.is_empty() { - // Refresh search results against current filter - self.rebuild_model_selector_items(); - } - } - - fn push_model_search_char(&mut self, ch: char) { - if ch.is_control() { - return; - } - self.model_search_query.push(ch); - self.rebuild_model_selector_items(); - self.update_model_search_status(); - } - - fn pop_model_search_char(&mut self) { - self.model_search_query.pop(); - self.rebuild_model_selector_items(); - self.update_model_search_status(); - } - - fn clear_model_search_query(&mut self) { - if self.model_search_query.is_empty() { - return; - } - self.model_search_query.clear(); - self.rebuild_model_selector_items(); - self.update_model_search_status(); - } - - fn reset_model_picker_state(&mut self) { - if !self.model_search_query.is_empty() { - self.model_search_query.clear(); - } - self.model_search_hits.clear(); - self.provider_search_hits.clear(); - self.visible_model_count = 0; - } - - fn update_model_search_status(&mut self) { - if !matches!( - self.mode, - InputMode::ModelSelection | InputMode::ProviderSelection - ) { - return; - } - if self.model_search_query.is_empty() { - self.status = "Select a model to use".to_string(); - } else { - let count = self.visible_model_count(); - if count == 1 { - self.status = format!("Search \"{}\" → 1 match", self.model_search_query.trim()); - } else { - self.status = format!( - "Search \"{}\" → {} matches", - self.model_search_query.trim(), - count - ); - } - } - } - - pub fn selected_model_item(&self) -> Option { - self.selected_model_item - } - - pub(crate) fn model_info_by_index(&self, index: usize) -> Option<&ModelInfo> { - self.models.get(index) - } - - pub fn cached_model_detail(&self, model_name: &str) -> Option<&DetailedModelInfo> { - self.model_details_cache.get(model_name) - } - - pub fn model_info_panel_mut(&mut self) -> &mut ModelInfoPanel { - &mut self.model_info_panel - } - - pub fn is_model_info_visible(&self) -> bool { - self.show_model_info - } - - pub fn set_model_info_visible(&mut self, visible: bool) { - if self.show_model_info != visible { - self.trigger_pane_pulse(PanePulse::ModelPanel); - } - self.show_model_info = visible; - if !visible { - self.model_info_panel.reset_scroll(); - self.model_info_viewport_height = 0; - } - } - - pub fn set_model_info_viewport_height(&mut self, height: usize) { - self.model_info_viewport_height = height; - } - - pub fn model_info_viewport_height(&self) -> usize { - self.model_info_viewport_height - } - - pub async fn ensure_model_details( - &mut self, - model_name: &str, - force_refresh: bool, - ) -> Result<()> { - if !force_refresh - && self.show_model_info - && self - .model_info_panel - .current_model_name() - .is_some_and(|name| name == model_name) - { - self.set_model_info_visible(false); - self.status = "Closed model info panel".to_string(); - self.error = None; - return Ok(()); - } - - if !force_refresh { - if let Some(info) = self.model_details_cache.get(model_name).cloned() { - self.model_info_panel.set_model_info(info); - self.set_model_info_visible(true); - self.status = format!("Showing model info for {}", model_name); - self.error = None; - return Ok(()); - } - } else { - self.model_details_cache.remove(model_name); - let controller = self.controller_lock_async().await; - controller.invalidate_model_details(model_name).await; - } - - let details_result = { - let controller = self.controller_lock_async().await; - controller.model_details(model_name, force_refresh).await - }; - - match details_result { - Ok(details) => { - self.model_details_cache - .insert(model_name.to_string(), details.clone()); - self.model_info_panel.set_model_info(details); - self.set_model_info_visible(true); - self.status = if force_refresh { - format!("Refreshed model info for {}", model_name) - } else { - format!("Showing model info for {}", model_name) - }; - self.error = None; - Ok(()) - } - Err(err) => { - self.error = Some(format!("Failed to load model info: {}", err)); - Err(err.into()) - } - } - } - - pub async fn prefetch_all_model_details(&mut self, force_refresh: bool) -> Result<()> { - if force_refresh { - let controller = self.controller_lock_async().await; - controller.clear_model_details_cache().await; - } - - let details_result = { - let controller = self.controller_lock_async().await; - controller.all_model_details(force_refresh).await - }; - - match details_result { - Ok(details) => { - if force_refresh { - self.model_details_cache.clear(); - } - for info in details { - self.model_details_cache.insert(info.name.clone(), info); - } - if let Some(current) = self - .model_info_panel - .current_model_name() - .map(|s| s.to_string()) - && let Some(updated) = self.model_details_cache.get(¤t).cloned() - { - self.model_info_panel.set_model_info(updated); - } - let total = self.model_details_cache.len(); - self.status = format!("Cached model details for {} model(s)", total); - self.error = None; - Ok(()) - } - Err(err) => { - self.error = Some(format!("Failed to prefetch model info: {}", err)); - Err(err.into()) - } - } - } - - pub fn auto_scroll(&self) -> &AutoScroll { - &self.auto_scroll - } - - pub fn auto_scroll_mut(&mut self) -> &mut AutoScroll { - &mut self.auto_scroll - } - - pub fn scroll(&self) -> usize { - self.auto_scroll.scroll - } - - pub fn thinking_scroll(&self) -> &AutoScroll { - &self.thinking_scroll - } - - pub fn thinking_scroll_mut(&mut self) -> &mut AutoScroll { - &mut self.thinking_scroll - } - - pub fn thinking_scroll_position(&self) -> usize { - self.thinking_scroll.scroll - } - - pub fn message_count(&self) -> usize { - self.conversation().messages.len() - } - - pub fn streaming_count(&self) -> usize { - self.streaming.len() - } - - pub fn formatter(&self) -> owlen_core::formatting::MessageFormatter { - let controller = self.controller_lock(); - controller.formatter().clone() - } - - pub fn input_buffer_text(&self) -> String { - let controller = self.controller_lock(); - controller.input_buffer().text().to_string() - } - - fn with_input_buffer_mut( - &self, - f: impl FnOnce(&mut owlen_core::input::InputBuffer) -> R, - ) -> R { - let mut controller = self.controller_lock(); - f(controller.input_buffer_mut()) - } - - fn with_conversation_manager_mut(&self, f: impl FnOnce(&mut ConversationManager) -> R) -> R { - let mut controller = self.controller_lock(); - f(controller.conversation_mut()) - } - - fn with_conversation_mut(&self, f: impl FnOnce(&mut Conversation) -> R) -> R { - self.with_conversation_manager_mut(|manager| f(manager.active_mut())) - } - - fn push_user_message(&self, content: String) -> Uuid { - self.with_conversation_manager_mut(|manager| manager.push_user_message(content)) - } - - fn push_user_message_with_attachments( - &self, - content: String, - attachments: Vec, - ) -> Uuid { - self.with_conversation_manager_mut(|manager| { - manager.push_user_message_with_attachments(content, attachments) - }) - } - - fn push_system_message(&self, content: String) -> Uuid { - self.with_conversation_manager_mut(|manager| manager.push_system_message(content)) - } - - fn push_assistant_message(&self, content: String) -> Uuid { - self.with_conversation_manager_mut(|manager| manager.push_assistant_message(content)) - } - - pub fn textarea(&self) -> &TextArea<'static> { - &self.textarea - } - - pub fn textarea_mut(&mut self) -> &mut TextArea<'static> { - &mut self.textarea - } - - pub fn system_status(&self) -> &str { - &self.system_status - } - - pub fn set_system_status(&mut self, status: String) { - self.system_status = status; - } - - pub fn append_system_status(&mut self, status: &str) { - if !self.system_status.is_empty() { - self.system_status.push_str(" | "); - } - self.system_status.push_str(status); - } - - pub fn clear_system_status(&mut self) { - self.system_status.clear(); - } - - pub fn show_tutorial(&mut self) { - self.open_guidance_overlay(GuidanceOverlay::Onboarding); - self.error = None; - self.status = TUTORIAL_STATUS.to_string(); - self.system_status = TUTORIAL_SYSTEM_STATUS.to_string(); - let tutorial_body = concat!( - "Keybindings overview:\n", - " • Movement: h/j/k/l, gg/G, w/b\n", - " • Insert text: i or a (Esc to exit)\n", - " • Visual select: v (Esc to exit)\n", - " • Command mode: : (press Enter to run, Esc to cancel)\n", - " • Send message: Enter in Insert mode\n", - " • Help overlay: F1 or ?\n" - ); - self.push_system_message(tutorial_body.to_string()); - } - - pub fn command_buffer(&self) -> &str { - self.command_palette.buffer() - } - - pub fn command_suggestions(&self) -> &[PaletteSuggestion] { - self.command_palette.suggestions() - } - - fn set_input_mode(&mut self, mode: InputMode) { - if self.mode != mode { - self.mode_flash_until = Some(Instant::now() + Duration::from_millis(240)); - } - if !matches!( - mode, - InputMode::ModelSelection | InputMode::ProviderSelection - ) && matches!( - self.mode, - InputMode::ModelSelection | InputMode::ProviderSelection - ) { - self.reset_model_picker_state(); - } - self.mode = mode; - self.keymap_state.reset(); - let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode })); - } - - fn open_guidance_overlay(&mut self, overlay: GuidanceOverlay) { - self.guidance_overlay = overlay; - if matches!(overlay, GuidanceOverlay::CheatSheet) && HELP_TAB_COUNT > 0 { - self.help_tab_index = self.help_tab_index.min(HELP_TAB_COUNT - 1); - } - if matches!(overlay, GuidanceOverlay::Onboarding) { - self.onboarding_step = 0; - self.status = format!("Owlen onboarding · Step 1 of {}", ONBOARDING_STEP_COUNT); - } else { - self.status = "Owlen cheat sheet".to_string(); - } - self.error = None; - self.set_input_mode(InputMode::Help); - } - - fn advance_onboarding_step(&mut self) { - if !matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) { - return; - } - if self.onboarding_step + 1 < ONBOARDING_STEP_COUNT { - self.onboarding_step += 1; - self.status = format!( - "Owlen onboarding · Step {} of {}", - self.onboarding_step + 1, - ONBOARDING_STEP_COUNT - ); - } else { - self.finish_onboarding(true); - } - } - - fn regress_onboarding_step(&mut self) { - if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) && self.onboarding_step > 0 - { - self.onboarding_step -= 1; - self.status = format!( - "Owlen onboarding · Step {} of {}", - self.onboarding_step + 1, - ONBOARDING_STEP_COUNT - ); - } - } - - fn finish_onboarding(&mut self, completed: bool) { - self.guidance_overlay = GuidanceOverlay::CheatSheet; - let (dirty, guidance_settings) = self.with_config_mut(|cfg| { - let mut dirty = false; - if cfg.ui.show_onboarding { - cfg.ui.show_onboarding = false; - dirty = true; - } - if completed && !cfg.ui.guidance.coach_marks_complete { - cfg.ui.guidance.coach_marks_complete = true; - dirty = true; - } - (dirty, cfg.ui.guidance.clone()) - }); - - self.guidance_settings = guidance_settings; - if dirty - && let Err(err) = self.with_config(config::save_config) - { - eprintln!("Warning: Failed to persist guidance settings: {err}"); - } - - if completed { - self.status = "Cheat sheet ready — press Esc when done".to_string(); - self.error = None; - if HELP_TAB_COUNT > 0 { - self.help_tab_index = 0; - } - self.set_input_mode(InputMode::Help); - } else { - self.reset_status(); - self.set_input_mode(InputMode::Normal); - } - } - - pub fn mode_flash_active(&self) -> bool { - self.mode_flash_until - .map(|deadline| Instant::now() < deadline) - .unwrap_or(false) - } - - pub fn selected_suggestion(&self) -> usize { - self.command_palette.selected_index() - } - - /// Returns all available commands with their aliases - /// Complete the current command with the selected suggestion - fn complete_command(&mut self) { - if let Some(suggestion) = self.command_palette.apply_selected() { - self.status = format!(":{}", suggestion); - } - } - - pub fn focused_panel(&self) -> FocusedPanel { - self.focused_panel - } - - pub fn visual_selection(&self) -> Option<((usize, usize), (usize, usize))> { - if let (Some(start), Some(end)) = (self.visual_start, self.visual_end) { - Some((start, end)) - } else { - None - } - } - - pub fn chat_cursor(&self) -> (usize, usize) { - self.chat_cursor - } - - pub fn thinking_cursor(&self) -> (usize, usize) { - self.thinking_cursor - } - - pub fn saved_sessions(&self) -> &[SessionMeta] { - &self.saved_sessions - } - - pub fn selected_session_index(&self) -> usize { - self.selected_session_index - } - - pub fn help_tab_index(&self) -> usize { - self.help_tab_index - } - - pub fn available_themes(&self) -> &[String] { - &self.available_themes - } - - pub fn selected_theme_index(&self) -> usize { - self.selected_theme_index - } - - pub fn theme(&self) -> &Theme { - &self.theme - } - - pub fn is_high_contrast_enabled(&self) -> bool { - self.accessibility_high_contrast - } - - pub fn is_reduced_chrome(&self) -> bool { - self.accessibility_reduced_chrome - } - - pub fn should_render_accessibility_legend(&self) -> bool { - self.accessibility_high_contrast || self.accessibility_reduced_chrome - } - - pub fn layer_settings(&self) -> &LayerSettings { - &self.layer_settings - } - - pub fn animation_settings(&self) -> &AnimationSettings { - &self.animation_settings - } - - pub fn micro_animations_enabled(&self) -> bool { - self.animation_settings.micro - && !self.accessibility_reduced_chrome - && !self.accessibility_high_contrast - } - - pub fn layout_mode(&self) -> AdaptiveLayout { - self.active_layout - } - - pub fn update_layout_mode(&mut self, layout: AdaptiveLayout) { - if self.active_layout != layout { - if self.micro_animations_enabled() { - self.pane_animations.trigger(PanePulse::Stage); - } - self.active_layout = layout; - } - } - - pub fn pane_glow(&mut self, pulse: PanePulse) -> f64 { - if !self.micro_animations_enabled() { - self.pane_animations.clear(pulse); - return 0.0; - } - self.pane_animations - .sample(pulse, self.animation_settings.pane_decay_factor()) - } - - pub fn animated_gauge_ratio(&mut self, key: GaugeKey, target: f64) -> f64 { - if !self.micro_animations_enabled() { - self.gauge_animations.snap(key, target); - return target.clamp(0.0, 1.0); - } - self.gauge_animations.sample( - key, - target, - self.animation_settings.gauge_smoothing_factor(), - ) - } - - pub fn reset_gauge(&mut self, key: GaugeKey) { - self.gauge_animations.snap(key, 0.0); - } - - fn trigger_pane_pulse(&mut self, pulse: PanePulse) { - if self.micro_animations_enabled() { - self.pane_animations.trigger(pulse); - } - } - - pub fn accessibility_status(&self) -> String { - Self::accessibility_summary( - self.accessibility_high_contrast, - self.accessibility_reduced_chrome, - ) - } - - pub fn accessibility_short_label(&self) -> Option { - let mut parts = Vec::new(); - if self.accessibility_high_contrast { - parts.push("HC"); - } - if self.accessibility_reduced_chrome { - parts.push("RC"); - } - if parts.is_empty() { - None - } else { - Some(parts.join("+")) - } - } - - pub fn base_theme_name(&self) -> &str { - &self.base_theme_name - } - - pub(crate) fn set_layout_snapshot(&mut self, snapshot: LayoutSnapshot) { - if self.micro_animations_enabled() { - let previous = self.last_layout; - if previous.file_panel.is_some() != snapshot.file_panel.is_some() { - self.trigger_pane_pulse(PanePulse::FilePanel); - } - if previous.code_panel.is_some() != snapshot.code_panel.is_some() { - self.trigger_pane_pulse(PanePulse::CodePanel); - } - if previous.model_info_panel.is_some() != snapshot.model_info_panel.is_some() { - self.trigger_pane_pulse(PanePulse::ModelPanel); - } - } - self.last_layout = snapshot; - } - - fn region_for_position(&self, column: u16, row: u16) -> Option { - self.last_layout.region_at(column, row) - } - - pub fn current_keymap_profile(&self) -> KeymapProfile { - self.current_keymap_profile - } - - pub fn keymap_leader(&self) -> &str { - &self.keymap_leader - } - - fn reload_keymap_from_config(&mut self) -> Result<()> { - let registry = CommandRegistry::default(); - let (keymap_path, keymap_profile, keymap_leader_raw) = self.with_config(|config| { - ( - config.ui.keymap_path.clone(), - config.ui.keymap_profile.clone(), - config.ui.keymap_leader.clone(), - ) - }); - - let overrides = KeymapOverrides::new(keymap_leader_raw); - self.keymap = Keymap::load( - keymap_path.as_deref(), - keymap_profile.as_deref(), - ®istry, - overrides.clone(), - ); - self.current_keymap_profile = self.keymap.profile(); - self.keymap_leader = overrides.leader().to_string(); - Ok(()) - } - - async fn switch_keymap_profile(&mut self, profile: KeymapProfile) -> Result<()> { - if self.current_keymap_profile == profile { - self.status = format!("Keymap already set to {}", profile.label()); - self.error = None; - return Ok(()); - } - - self.with_config_mut(|cfg| { - cfg.ui.keymap_profile = Some(profile.config_value().to_string()); - cfg.ui.keymap_path = None; - config::save_config(cfg) - })?; - - self.reload_keymap_from_config()?; - self.status = format!("Keymap switched to {}", profile.label()); - self.error = None; - Ok(()) - } - - pub fn is_debug_log_visible(&self) -> bool { - self.debug_log.is_visible() - } - - pub fn toggle_debug_log_panel(&mut self) { - let now_visible = self.debug_log.toggle_visible(); - self.trigger_pane_pulse(PanePulse::DebugPanel); - if now_visible { - self.status = "Debug log open — F12 to hide".to_string(); - self.error = None; - } else { - self.status = "Debug log hidden".to_string(); - self.error = None; - } - } - - pub fn debug_log_entries(&self) -> Vec { - self.debug_log.entries() - } - - pub fn toasts(&self) -> impl Iterator { - self.toasts.iter() - } - - pub fn toast_history(&self) -> impl Iterator { - self.toasts.history() - } - - pub fn push_toast(&mut self, level: ToastLevel, message: impl Into) { - self.toasts.push(message, level); - } - - pub fn push_toast_with_hint( - &mut self, - level: ToastLevel, - message: impl Into, - shortcut_hint: impl Into, - ) { - self.toasts.push_with_hint(message, level, shortcut_hint); - } - - fn is_backend_busy(&self) -> bool { - self.pending_llm_request - || !self.streaming.is_empty() - || self.pending_tool_execution.is_some() - || self.pending_consent.is_some() - } - - fn should_queue_submission(&self) -> bool { - self.queue_paused - || self.is_backend_busy() - || self.active_command.is_some() - || !self.command_queue.is_empty() - } - - fn enqueue_submission( - &mut self, - content: String, - attachments: Vec, - source: QueueSource, - ) { - let trimmed = content.trim(); - if trimmed.is_empty() && attachments.is_empty() { - self.error = Some("Cannot queue empty message".to_string()); - return; - } - - let entry = QueuedCommand::new(trimmed.to_string(), attachments, source); - self.command_queue.push_back(entry); - let pending = self.command_queue.len(); - if self.queue_paused { - self.status = format!("Queued request · {pending} pending (queue paused)"); - } else { - self.status = format!("Queued request · {pending} pending"); - } - self.push_toast( - ToastLevel::Info, - format!("Queued request — {pending} pending"), - ); - - self.start_next_queued_command(); - self.update_queue_status(); - } - - fn start_user_turn_internal( - &mut self, - content: String, - attachments: Vec, - source: QueueSource, - attempts: u8, - enqueued_at: DateTime, - ) { - let message_body = content.trim(); - if message_body.is_empty() && attachments.is_empty() { - self.error = Some("Cannot send empty message".to_string()); - self.status = "Message discarded".to_string(); - return; - } - - let mut references = Self::extract_resource_references(message_body); - references.sort(); - references.dedup(); - self.pending_resource_refs = references; - - let attachments_for_message = attachments.clone(); - let _message_id = if attachments_for_message.is_empty() { - self.push_user_message(message_body.to_string()) - } else { - self.push_user_message_with_attachments( - message_body.to_string(), - attachments_for_message, - ) - }; - - self.refresh_attachment_gallery(); - - self.auto_scroll.stick_to_bottom = true; - self.pending_llm_request = true; - if matches!(source, QueueSource::User) && attempts == 0 { - self.status = "Message sent".to_string(); - } - self.error = None; - - self.active_command = Some(ActiveCommand::new( - message_body.to_string(), - attachments, - source, - attempts, - enqueued_at, - )); - } - - async fn attach_file(&mut self, raw_path: &str) -> Result<()> { - let expanded = shellexpand::tilde(raw_path).into_owned(); - let cleaned = expanded.trim(); - if cleaned.is_empty() { - return Err(anyhow!("Attachment path cannot be empty")); - } - - let path = PathBuf::from(cleaned); - let metadata = tokio::fs::metadata(&path) - .await - .with_context(|| format!("Unable to inspect {}", path.display()))?; - if !metadata.is_file() { - return Err(anyhow!("{} is not a file", path.display())); - } - if metadata.len() > MAX_ATTACHMENT_BYTES { - return Err(anyhow!(format!( - "Attachments are limited to {} (requested {}): {}", - Self::format_attachment_size(MAX_ATTACHMENT_BYTES), - Self::format_attachment_size(metadata.len()), - path.display() - ))); - } - - let bytes = tokio::fs::read(&path) - .await - .with_context(|| format!("Failed to read {}", path.display()))?; - let mime = mime_guess::from_path(&path).first_or_octet_stream(); - let mime_string = mime.essence_str().to_string(); - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("attachment") - .to_string(); - - let mut attachment = - if mime_string.starts_with("text/") || std::str::from_utf8(&bytes).is_ok() { - let text = String::from_utf8_lossy(&bytes).into_owned(); - let mut attachment = MessageAttachment::from_text( - Some(file_name.clone()), - mime_string.clone(), - text.clone(), - ); - attachment.size_bytes = Some(metadata.len()); - let preview = Self::preview_lines_for_text(&text); - if !preview.is_empty() { - attachment = attachment.with_preview_lines(preview); - } - attachment - } else { - let encoded = BASE64_STANDARD.encode(&bytes); - let mut attachment = MessageAttachment::from_base64( - file_name.clone(), - mime_string.clone(), - encoded, - Some(metadata.len()), - ); - if let Some(preview) = Self::preview_lines_for_image(&bytes) { - attachment = attachment.with_preview_lines(preview); - } - attachment - }; - - attachment.size_bytes = Some(metadata.len()); - attachment = attachment - .with_source_path(path.clone()) - .with_description(format!( - "{} ({})", - path.display(), - Self::format_attachment_size(metadata.len()) - )); - - self.pending_attachments.push(attachment); - self.refresh_attachment_gallery(); - self.status = format!( - "Attached {} ({})", - path.display(), - Self::format_attachment_size(metadata.len()) - ); - self.error = None; - self.push_toast( - ToastLevel::Info, - format!("Attachment staged: {}", file_name), - ); - Ok(()) - } - - fn start_user_turn_from_queue(&mut self, entry: QueuedCommand) { - let pending = self.command_queue.len(); - self.status = if pending == 0 { - "Processing queued request".to_string() - } else { - format!("Processing queued request · {pending} remaining") - }; - self.start_user_turn_internal( - entry.content, - entry.attachments, - entry.source, - entry.attempts, - entry.enqueued_at, - ); - } - - fn start_next_queued_command(&mut self) { - if self.queue_paused || self.is_backend_busy() || self.active_command.is_some() { - return; - } - if let Some(entry) = self.command_queue.pop_front() { - self.start_user_turn_from_queue(entry); - } - self.update_queue_status(); - } - - fn mark_active_command_succeeded(&mut self) { - if let Some(active) = self.active_command.take() { - self.capture_thought_summary(active.response_id); - } - - if !self.command_queue.is_empty() { - let pending = self.command_queue.len(); - self.status = format!("Ready · {pending} queued"); - } else if self.status.to_ascii_lowercase().contains("queued") { - self.status = "Ready".to_string(); - } - - self.start_next_queued_command(); - self.update_queue_status(); - } - - fn mark_active_command_failed(&mut self, reason: Option) { - if let Some(message) = reason.as_ref() { - self.push_toast(ToastLevel::Warning, message.clone()); - } - - if let Some(active) = self.active_command.take() { - if let Some(entry) = QueuedCommand::from_active(active) { - self.command_queue.push_front(entry); - let pending = self.command_queue.len(); - self.status = format!("Request queued for retry · {pending} pending"); - } else { - self.status = - "Request failed repeatedly and was removed from the queue".to_string(); - } - } - - self.start_next_queued_command(); - self.update_queue_status(); - } - - fn capture_thought_summary(&mut self, response_hint: Option) { - let conversation = self.conversation(); - let maybe_message = if let Some(id) = response_hint { - conversation - .messages - .iter() - .find(|msg| msg.id == id && matches!(msg.role, Role::Assistant)) - } else { - conversation - .messages - .iter() - .rev() - .find(|msg| matches!(msg.role, Role::Assistant)) - }; - - let Some(message) = maybe_message else { - return; - }; - - let formatter = self.formatter(); - let (_, thinking) = formatter.extract_thinking(&message.content); - let Some(thinking) = thinking else { - return; - }; - - let Some(summary) = Self::summarize_thinking(&thinking) else { - return; - }; - - if self.latest_thought_summary.as_deref() == Some(summary.as_str()) { - return; - } - - self.latest_thought_summary = Some(summary.clone()); - self.thought_summaries.push_front(summary.clone()); - while self.thought_summaries.len() > THOUGHT_SUMMARY_LIMIT { - self.thought_summaries.pop_back(); - } - - self.push_toast_with_hint( - ToastLevel::Info, - format!("Thought summary: {}", summary), - ":queue status", - ); - self.set_system_status(format!("🧠 {}", summary)); - } - - fn update_queue_status(&mut self) { - if !self.command_queue.is_empty() || self.active_command.is_some() { - self.set_system_status(self.queue_status_summary()); - } - } - - fn queue_status_summary(&self) -> String { - let pending = self.command_queue.len(); - let paused_flag = if self.queue_paused { - "paused" - } else { - "running" - }; - let active_label = if let Some(active) = &self.active_command { - let attempt = active.attempts as usize + 1; - format!("active {} (attempt {})", active.source.label(), attempt) - } else { - "idle".to_string() - }; - let summary = self - .latest_thought_summary - .as_deref() - .unwrap_or("no summary yet"); - format!( - "Queue: {pending} pending · {paused_flag} · {active_label} · last summary: {summary}" - ) - } - - fn run_queue_command(&mut self, args: &[&str]) { - if args.is_empty() { - self.status = self.queue_status_summary(); - self.error = None; - return; - } - - match args[0].to_ascii_lowercase().as_str() { - "status" => { - self.status = self.queue_status_summary(); - self.error = None; - } - "pause" => { - self.queue_paused = true; - let pending = self.command_queue.len(); - self.status = format!("Queue paused · {pending} pending"); - self.error = None; - } - "resume" => { - self.queue_paused = false; - let pending = self.command_queue.len(); - self.status = format!("Queue resumed · {pending} pending"); - self.error = None; - self.start_next_queued_command(); - } - "clear" => { - let cleared = self.command_queue.len(); - self.command_queue.clear(); - self.status = format!("Cleared queue · removed {cleared} entries"); - self.error = None; - } - "next" => { - if self.is_backend_busy() || self.active_command.is_some() { - self.status = "A request is already active; cannot start next.".to_string(); - self.error = None; - } else if let Some(entry) = self.command_queue.pop_front() { - self.start_user_turn_from_queue(entry); - } else { - self.status = "Queue is empty".to_string(); - self.error = None; - } - } - other => { - self.error = Some(format!( - "Unknown queue action '{}'. Use pause, resume, status, next, or clear.", - other - )); - self.status = "Usage: :queue [status|pause|resume|next|clear]".to_string(); - } - } - } - - fn active_agent_profile(&self) -> Option<&AgentProfile> { - self.active_agent_id - .as_deref() - .and_then(|id| self.agent_registry.get(id)) - } - - fn ensure_active_agent(&mut self) -> Result<()> { - if self.active_agent_profile().is_some() { - return Ok(()); - } - - if let Some(profile) = self.agent_registry.profiles().first() { - let id = profile.id.clone(); - let display_name = profile.display_name().to_string(); - self.active_agent_id = Some(id); - self.error = None; - self.set_system_status(format!("🤖 Ready · {}", display_name)); - Ok(()) - } else { - let message = "No agent profiles found. Create .owlen/agents/*.toml or ~/.config/owlen/agents/*.toml"; - self.error = Some(message.to_string()); - self.status = message.to_string(); - Err(anyhow!(message)) - } - } - - fn set_active_agent_from_query(&mut self, query: &str) -> Result<()> { - let trimmed = query.trim(); - if trimmed.is_empty() { - return Err(anyhow!("Usage: :agent use ")); - } - - let lookup = trimmed.to_ascii_lowercase(); - let profile = self - .agent_registry - .profiles() - .iter() - .find(|profile| { - profile.id.eq_ignore_ascii_case(trimmed) - || profile.display_name().to_ascii_lowercase() == lookup - }) - .ok_or_else(|| { - anyhow!(format!( - "Unknown agent '{trimmed}'. Use :agent list to view available agents." - )) - })?; - - let id = profile.id.clone(); - let display_name = profile.display_name().to_string(); - - self.active_agent_id = Some(id); - self.status = format!("Active agent: {}", display_name); - self.error = None; - self.set_system_status(format!("🤖 Ready · {}", display_name)); - Ok(()) - } - - fn describe_agents(&self) -> String { - if self.agent_registry.profiles().is_empty() { - return "No agent profiles found. Add .toml files under ~/.config/owlen/agents or ./.owlen/agents.".to_string(); - } - - self.agent_registry - .profiles() - .iter() - .map(|profile| { - let is_active = self - .active_agent_id - .as_deref() - .map(|id| id.eq_ignore_ascii_case(&profile.id)) - .unwrap_or(false); - let marker = if is_active { '*' } else { ' ' }; - let label = profile.name.as_deref().unwrap_or("(unnamed)"); - let description = profile.description.as_deref().unwrap_or(""); - if description.is_empty() { - format!("{marker} {} — {}", profile.id, label) - } else { - format!("{marker} {} — {} — {description}", profile.id, label) - } - }) - .collect::>() - .join("\n") - } - - fn prune_toasts(&mut self) { - self.toasts.retain_active(); - } - - fn poll_debug_log_updates(&mut self) { - let new_entries = self.debug_log.take_unseen(); - if new_entries.is_empty() { - return; - } - - let mut latest_summary: Option<(Level, String)> = None; - - for entry in new_entries.iter() { - let toast_level = match entry.level { - Level::Error => ToastLevel::Error, - Level::Warn => ToastLevel::Warning, - _ => continue, - }; - - let summary = format!("{}: {}", entry.target, entry.message); - let clipped = Self::ellipsize(&summary, 120); - self.push_toast_with_hint(toast_level, clipped.clone(), "F12 · Debug log"); - latest_summary = Some((entry.level, clipped)); - } - - if !self.debug_log.is_visible() - && let Some((level, message)) = latest_summary - { - let level_label = match level { - Level::Error => "Error", - Level::Warn => "Warning", - _ => "Log", - }; - self.status = format!("{level_label}: {message} (F12 to open debug log)"); - self.error = None; - } - } - - fn ellipsize(message: &str, max_len: usize) -> String { - if message.chars().count() <= max_len { - return message.to_string(); - } - - let mut truncated = String::new(); - for (idx, ch) in message.chars().enumerate() { - if idx + 1 >= max_len { - truncated.push('…'); - break; - } - truncated.push(ch); - } - truncated - } - - pub fn input_max_rows(&self) -> u16 { - self.with_config(|config| config.ui.input_max_rows.max(1)) - } - - pub fn active_model_label(&self) -> String { - let active_id = { - let controller = self.controller_lock(); - controller.selected_model().to_string() - }; - if let Some(model) = self - .models - .iter() - .find(|m| m.id == active_id || m.name == active_id) - { - Self::display_name_for_model(model) - } else { - active_id - } - } - - pub fn is_loading(&self) -> bool { - self.is_loading - } - - pub fn is_streaming(&self) -> bool { - !self.streaming.is_empty() - } - - pub fn scrollback_limit(&self) -> usize { - let limit = self.with_config(|config| config.ui.scrollback_lines); - if limit == 0 { usize::MAX } else { limit } - } - - pub fn has_new_message_alert(&self) -> bool { - self.new_message_alert - } - - pub fn clear_new_message_alert(&mut self) { - self.new_message_alert = false; - } - - fn notify_new_activity(&mut self) { - if !self.auto_scroll.stick_to_bottom { - self.new_message_alert = true; - } - } - - fn update_new_message_alert_after_scroll(&mut self) { - if self.auto_scroll.stick_to_bottom { - self.clear_new_message_alert(); - } - } - - fn model_palette_entries(&self) -> Vec { - self.models - .iter() - .map(|model| ModelPaletteEntry { - id: model.id.clone(), - name: model.name.clone(), - provider: model.provider.clone(), - }) - .collect() - } - - fn update_command_palette_catalog(&mut self) { - let providers = self.available_providers.clone(); - let models = self.model_palette_entries(); - self.command_palette - .update_dynamic_sources(models, providers); - } - - async fn refresh_resource_catalog(&mut self) -> Result<()> { - let mut resources = { - let controller = self.controller_lock_async().await; - controller.configured_resources().await - }; - resources.sort_by(|a, b| a.server.cmp(&b.server).then(a.uri.cmp(&b.uri))); - self.resource_catalog = resources; - Ok(()) - } - - async fn refresh_mcp_slash_commands(&mut self) -> Result<()> { - let mut commands = Vec::new(); - let tools = { - let controller = self.controller_lock_async().await; - controller.list_mcp_tools().await - }; - for (server, descriptor) in tools { - if !Self::tool_supports_slash(&descriptor) { - continue; - } - let description = if descriptor.description.trim().is_empty() { - None - } else { - Some(descriptor.description.clone()) - }; - commands.push(McpSlashCommand::new( - server, - descriptor.name.clone(), - description, - )); - } - slash::set_mcp_commands(commands); - Ok(()) - } - - fn tool_supports_slash(descriptor: &McpToolDescriptor) -> bool { - if descriptor.name.trim().is_empty() { - return false; - } - Self::tool_allows_empty_arguments(&descriptor.input_schema) - } - - fn tool_allows_empty_arguments(schema: &Value) -> bool { - match schema { - Value::Object(map) => { - if let Some(Value::Array(required)) = map.get("required") { - !required - .iter() - .any(|entry| entry.as_str().is_some_and(|s| !s.is_empty())) - } else { - true - } - } - _ => true, - } - } - - fn format_mcp_slash_message(server: &str, tool: &str, response: &McpToolResponse) -> String { - let status = if response.success { "✓" } else { "✗" }; - let payload = if response.success { - Self::extract_mcp_primary_text(&response.output) - } else { - Self::extract_mcp_error(&response.output) - .or_else(|| Self::extract_mcp_primary_text(&response.output)) - } - .unwrap_or_else(|| Self::pretty_print_value(&response.output)); - - if payload.trim().is_empty() { - return format!("MCP {server}::{tool} {status}"); - } - - if payload.contains('\n') { - format!("MCP {server}::{tool} {status}\n```json\n{payload}\n```") - } else { - format!("MCP {server}::{tool} {status}\n{payload}") - } - } - - fn extract_mcp_primary_text(value: &Value) -> Option { - if let Some(text) = value.as_str().filter(|text| !text.trim().is_empty()) { - return Some(text.to_string()); - } - - if let Value::Object(map) = value { - const CANDIDATES: [&str; 6] = - ["rendered", "text", "content", "value", "message", "body"]; - for key in CANDIDATES { - if let Some(Value::String(text)) = map.get(key) - && !text.trim().is_empty() - { - return Some(text.clone()); - } - } - - if let Some(Value::Array(items)) = map.get("lines") { - let mut collected = Vec::new(); - for item in items { - if let Some(segment) = item.as_str() - && !segment.trim().is_empty() - { - collected.push(segment.trim()); - } - } - if !collected.is_empty() { - return Some(collected.join("\n")); - } - } - } - - None - } - - fn extract_mcp_error(value: &Value) -> Option { - if let Value::Object(map) = value - && let Some(Value::String(message)) = map.get("error") - && !message.trim().is_empty() - { - return Some(message.clone()); - } - None - } - - fn pretty_print_value(value: &Value) -> String { - serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) - } - - async fn resolve_pending_resource_references(&mut self) -> Result<()> { - if self.pending_resource_refs.is_empty() { - return Ok(()); - } - - let mut resolved = 0usize; - let references: Vec = self.pending_resource_refs.drain(..).collect(); - for reference in references { - let resolution = { - let controller = self.controller_lock_async().await; - controller.resolve_resource_reference(&reference).await - }; - match resolution { - Ok(Some(content)) => { - let message = format!("Resource @{}:\n{}", reference, content); - self.push_system_message(message); - resolved += 1; - } - Ok(None) => { - self.push_toast( - ToastLevel::Warning, - format!( - "Resource @{} is not defined in the current project.", - reference - ), - ); - } - Err(err) => { - self.push_toast( - ToastLevel::Error, - format!("Failed to load resource @{}: {}", reference, err), - ); - } - } - } - - if resolved > 0 { - self.status = format!("Inserted {resolved} resource snippet(s)."); - } - - Ok(()) - } - - fn complete_resource_reference(&mut self) -> bool { - if self.resource_catalog.is_empty() { - return false; - } - - let (row, col) = self.textarea.cursor(); - let lines = self.textarea.lines().to_vec(); - if row >= lines.len() { - return false; - } - - let line = &lines[row]; - let chars: Vec = line.chars().collect(); - if col > chars.len() { - return false; - } - - let mut start = col; - while start > 0 { - let ch = chars[start - 1]; - if ch == '@' { - start -= 1; - break; - } - if ch.is_whitespace() { - return false; - } - start -= 1; - } - - if start >= col || chars.get(start) != Some(&'@') { - return false; - } - - if chars[start + 1..col].iter().any(|ch| ch.is_whitespace()) { - return false; - } - - let mut end = col; - while end < chars.len() { - let ch = chars[end]; - if ch.is_whitespace() { - break; - } - end += 1; - } - - let typed_prefix: String = chars[start + 1..col].iter().collect(); - let trailing_segment: String = chars[col..end].iter().collect(); - let lower_prefix = typed_prefix.to_ascii_lowercase(); - let lower_full = format!("{}{}", typed_prefix, trailing_segment).to_ascii_lowercase(); - - let mut matches: Vec<&McpResourceConfig> = self - .resource_catalog - .iter() - .filter(|resource| { - let reference = format!("{}:{}", resource.server, resource.uri); - let lower_reference = reference.to_ascii_lowercase(); - lower_reference.starts_with(&lower_full) - || lower_reference.starts_with(&lower_prefix) - || resource - .title - .as_ref() - .map(|title| title.to_ascii_lowercase().starts_with(&lower_prefix)) - .unwrap_or(false) - }) - .collect(); - - if matches.is_empty() { - return false; - } - - matches.sort_by(|a, b| a.server.cmp(&b.server).then(a.uri.cmp(&b.uri))); - let (selected_server, selected_uri, selected_title) = { - let selected = matches[0]; - ( - selected.server.clone(), - selected.uri.clone(), - selected.title.clone(), - ) - }; - let replacement = format!("@{}:{}", selected_server, selected_uri); - - let mut new_line = String::new(); - new_line.extend(chars[..start].iter()); - new_line.push_str(&replacement); - new_line.extend(chars[end..].iter()); - - let mut new_lines = lines; - new_lines[row] = new_line; - self.textarea = TextArea::new(new_lines); - configure_textarea_defaults(&mut self.textarea); - - let new_col = start + replacement.len(); - self.textarea - .move_cursor(CursorMove::Jump(row as u16, new_col as u16)); - - self.sync_textarea_to_buffer(); - - if let Some(title) = selected_title.as_deref() { - self.status = format!("Inserted resource {} ({title}).", replacement); - } else { - self.status = format!("Inserted resource {}.", replacement); - } - self.error = None; - - true - } - - fn extract_resource_references(text: &str) -> Vec { - let mut references = Vec::new(); - let mut current = String::new(); - let mut in_reference = false; - - for ch in text.chars() { - if in_reference { - if ch.is_whitespace() || matches!(ch, ',' | ';' | ')' | '(' | '.' | '!' | '?') { - if current.contains(':') { - references.push(current.clone()); - } - current.clear(); - in_reference = false; - } else { - current.push(ch); - } - } else if ch == '@' { - in_reference = true; - current.clear(); - } - } - - if in_reference && current.contains(':') { - references.push(current); - } - - references - } - - fn refresh_attachment_gallery(&mut self) { - let mut entries = Vec::new(); - let mut source = AttachmentPreviewSource::None; - - if !self.pending_attachments.is_empty() { - entries = self - .pending_attachments - .iter() - .map(|attachment| self.build_attachment_entry(attachment)) - .collect(); - source = AttachmentPreviewSource::Pending; - } else if let Some(message) = self.latest_message_with_attachments() { - entries = message - .attachments - .iter() - .map(|attachment| self.build_attachment_entry(attachment)) - .collect(); - if !entries.is_empty() { - source = AttachmentPreviewSource::Message(message.id); - } - } - - if entries.is_empty() { - self.attachment_preview_entries.clear(); - self.attachment_preview_selection = 0; - self.attachment_preview_source = AttachmentPreviewSource::None; - } else { - let max_index = entries.len().saturating_sub(1); - self.attachment_preview_selection = self.attachment_preview_selection.min(max_index); - self.attachment_preview_entries = entries; - self.attachment_preview_source = source; - } - } - - fn latest_message_with_attachments(&self) -> Option { - self.conversation() - .messages - .iter() - .rev() - .find(|message| !message.attachments.is_empty()) - .cloned() - } - - fn build_attachment_entry(&self, attachment: &MessageAttachment) -> AttachmentPreviewEntry { - let preview_lines = self.generate_attachment_preview_lines(attachment); - let summary = Self::summarize_attachment(attachment); - AttachmentPreviewEntry { - summary, - preview_lines, - } - } - - fn generate_attachment_preview_lines(&self, attachment: &MessageAttachment) -> Vec { - if let Some(lines) = attachment.preview_lines.clone() { - return lines; - } - - if let Some(text) = attachment.text_data() { - return Self::preview_lines_for_text(text); - } - - if attachment.is_image() { - if let Some(data) = attachment.base64_data() - && let Ok(bytes) = BASE64_STANDARD.decode(data) - && let Some(lines) = Self::preview_lines_for_image(&bytes) - { - return lines; - } else if let Some(path) = attachment.source_path.as_ref() - && let Ok(bytes) = fs::read(path) - && let Some(lines) = Self::preview_lines_for_image(&bytes) - { - return lines; - } - } - - Vec::new() - } - - pub(crate) fn attachment_preview_height(&self) -> u16 { - if self.attachment_preview_entries.is_empty() { - return 0; - } - - let list_height = (self.attachment_preview_entries.len() as u16).min(4); - let preview_lines = self - .attachment_preview_entries - .get(self.attachment_preview_selection) - .map(|entry| entry.preview_lines.len() as u16) - .unwrap_or(0) - .min(ATTACHMENT_ASCII_HEIGHT as u16); - - let base = 3u16; // header + padding - (base + list_height + preview_lines).min(ATTACHMENT_ASCII_HEIGHT as u16 + 6) - } - - pub(crate) fn attachment_preview_entries(&self) -> &[AttachmentPreviewEntry] { - &self.attachment_preview_entries - } - - pub(crate) fn attachment_preview_selection(&self) -> usize { - if self.attachment_preview_entries.is_empty() { - 0 - } else { - self.attachment_preview_selection - .min(self.attachment_preview_entries.len() - 1) - } - } - - pub(crate) fn attachment_preview_source(&self) -> AttachmentPreviewSource { - self.attachment_preview_source - } - - pub(crate) fn shift_attachment_selection(&mut self, delta: isize) { - if self.attachment_preview_entries.is_empty() { - self.status = "No attachments to select".to_string(); - self.error = None; - return; - } - - let len = self.attachment_preview_entries.len() as isize; - if len == 0 { - return; - } - - let current = self.attachment_preview_selection as isize; - let mut next = current + delta; - while next < 0 { - next += len; - } - while next >= len { - next -= len; - } - - self.attachment_preview_selection = next as usize; - if let Some(entry) = self - .attachment_preview_entries - .get(self.attachment_preview_selection) - { - self.status = format!( - "Viewing attachment {} — {}", - self.attachment_preview_selection + 1, - entry.summary - ); - self.error = None; - } - } - - fn summarize_attachment(attachment: &MessageAttachment) -> String { - let icon = if attachment.is_image() { - "📷" - } else if attachment - .mime_type - .to_ascii_lowercase() - .starts_with("text/") - { - "📄" - } else { - "📎" - }; - let name = attachment - .name - .as_deref() - .unwrap_or(attachment.mime_type.as_str()); - let mut parts = Vec::new(); - parts.push(format!("{icon} {name}")); - parts.push(attachment.mime_type.clone()); - if let Some(size) = attachment.size_bytes { - parts.push(Self::format_attachment_size(size)); - } - parts.join(" · ") - } - - async fn repo_commit_template_markdown(&self, working_tree: bool) -> Result { - let repo_root = self.file_tree.root().to_path_buf(); - let mode = if working_tree { - DiffCaptureMode::WorkingTree - } else { - DiffCaptureMode::Staged - }; - let markdown = task::spawn_blocking(move || -> Result { - let automation = - RepoAutomation::from_path(&repo_root).map_err(|err| anyhow!(err.to_string()))?; - let template = automation - .generate_commit_template(mode) - .map_err(|err| anyhow!(err.to_string()))?; - Ok(template.render_markdown()) - }) - .await - .map_err(|err| anyhow!(err.to_string()))??; - Ok(markdown) - } - - async fn repo_review_markdown( - &self, - base: Option, - head: Option, - ) -> Result { - let repo_root = self.file_tree.root().to_path_buf(); - let base_clone = base.clone(); - let head_clone = head.clone(); - let markdown = task::spawn_blocking(move || -> Result { - let automation = - RepoAutomation::from_path(&repo_root).map_err(|err| anyhow!(err.to_string()))?; - let review = automation - .generate_pr_review(base_clone.as_deref(), head_clone.as_deref()) - .map_err(|err| anyhow!(err.to_string()))?; - Ok(review.render_markdown()) - }) - .await - .map_err(|err| anyhow!(err.to_string()))??; - Ok(markdown) - } - - fn format_attachment_size(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; - let mut value = bytes as f64; - let mut index = 0usize; - while value >= 1024.0 && index < UNITS.len() - 1 { - value /= 1024.0; - index += 1; - } - if index == 0 { - format!("{bytes} {}", UNITS[index]) - } else { - format!("{value:.1} {}", UNITS[index]) - } - } - - fn preview_lines_for_text(text: &str) -> Vec { - let mut lines = Vec::new(); - for raw in text.lines().take(ATTACHMENT_TEXT_PREVIEW_LINES) { - let trimmed = raw.trim_end(); - if trimmed.is_empty() { - lines.push(String::new()); - continue; - } - let mut snippet = trimmed - .chars() - .take(ATTACHMENT_TEXT_PREVIEW_WIDTH) - .collect::(); - if trimmed.chars().count() > ATTACHMENT_TEXT_PREVIEW_WIDTH { - snippet.push('…'); - } - lines.push(snippet); - } - - if lines.is_empty() { - lines.push("(empty attachment)".to_string()); - } - - lines - } - - fn preview_lines_for_image(bytes: &[u8]) -> Option> { - let image = image::load_from_memory(bytes).ok()?; - let (width, height) = image.dimensions(); - let mut lines = Vec::new(); - lines.push(format!("{width} × {height} px")); - - let target_width = ATTACHMENT_ASCII_WIDTH; - let target_height = ATTACHMENT_ASCII_HEIGHT; - let scale = (target_width as f32 / width as f32) - .min(target_height as f32 / height as f32) - .clamp(0.05, 1.0); - let scaled_width = (width as f32 * scale).max(1.0).round() as u32; - let scaled_height = (height as f32 * scale).max(1.0).round() as u32; - let resized = image - .resize_exact( - scaled_width.max(1), - scaled_height.max(1), - FilterType::Triangle, - ) - .to_luma8(); - - const PALETTE: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@']; - for y in 0..resized.height() { - let mut row = String::with_capacity((resized.width() as usize) * 2); - for x in 0..resized.width() { - let luminance = resized.get_pixel(x, y)[0] as usize; - let idx = luminance * (PALETTE.len() - 1) / 255; - let ch = PALETTE[idx]; - row.push(ch); - row.push(ch); - } - lines.push(row); - } - - Some(lines) - } - - pub(crate) fn display_name_for_model(model: &ModelInfo) -> String { - let base = { - let trimmed = model.name.trim(); - if trimmed.is_empty() { - model.id.as_str() - } else { - trimmed - } - }; - - let scope = Self::model_scope_from_capabilities(model); - let scope_suffix = match &scope { - ModelScope::Local => "local".to_string(), - ModelScope::Cloud => "cloud".to_string(), - ModelScope::Other(other) => other.trim().to_ascii_lowercase(), - }; - - if scope_suffix.is_empty() { - base.to_string() - } else { - let lower = base.to_ascii_lowercase(); - if lower.contains(&format!("· {}", scope_suffix)) { - base.to_string() - } else { - format!("{base} · {scope_suffix}") - } - } - } - - fn role_style(theme: &Theme, role: &Role) -> Style { - match role { - Role::User => Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.user_message_role, - )), - Role::Assistant => Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.assistant_message_role, - )), - Role::System => { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.mode_command)) - } - Role::Tool => Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - } - } - - fn message_border_style(theme: &Theme, role: &Role) -> Style { - let base_color = match role { - Role::User => theme.user_message_role, - Role::Assistant => theme.assistant_message_role, - Role::System => theme.mode_command, - Role::Tool => theme.info, - }; - - let base_color_ratatui = crate::color_convert::to_ratatui_color(&base_color); - let dimmed = Self::dim_color(base_color_ratatui); - - Style::default().fg(dimmed).add_modifier(Modifier::DIM) - } - - fn content_style(theme: &Theme, role: &Role) -> Style { - if matches!(role, Role::Tool) { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.tool_output)) - } else { - Style::default() - } - } - - fn dim_color(color: Color) -> Color { - match color { - Color::Reset | Color::Indexed(_) => color, - _ => { - if let Some((r, g, b)) = Self::color_to_rgb(color) { - let dim_component = |component: u8| -> u8 { - let value = ((component as u16) * 2) / 5; - value as u8 - }; - Color::Rgb(dim_component(r), dim_component(g), dim_component(b)) - } else { - color - } - } - } - } - - fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> { - match color { - Color::Black => Some((0, 0, 0)), - Color::Red => Some((205, 0, 0)), - Color::Green => Some((0, 205, 0)), - Color::Yellow => Some((205, 205, 0)), - Color::Blue => Some((0, 0, 205)), - Color::Magenta => Some((205, 0, 205)), - Color::Cyan => Some((0, 205, 205)), - Color::Gray => Some((170, 170, 170)), - Color::DarkGray => Some((85, 85, 85)), - Color::LightRed => Some((255, 85, 85)), - Color::LightGreen => Some((85, 255, 85)), - Color::LightYellow => Some((255, 255, 85)), - Color::LightBlue => Some((85, 85, 255)), - Color::LightMagenta => Some((255, 85, 255)), - Color::LightCyan => Some((85, 255, 255)), - Color::White => Some((255, 255, 255)), - Color::Rgb(r, g, b) => Some((r, g, b)), - Color::Reset | Color::Indexed(_) => None, - } - } - - fn message_content_hash( - role: &Role, - content: &str, - tool_signature: &str, - attachment_signature: &str, - ) -> u64 { - let mut hasher = DefaultHasher::new(); - role.to_string().hash(&mut hasher); - content.hash(&mut hasher); - tool_signature.hash(&mut hasher); - attachment_signature.hash(&mut hasher); - hasher.finish() - } - - fn invalidate_message_cache(&mut self, id: &Uuid) { - self.message_line_cache.remove(id); - } - - fn sync_ui_preferences_from_config(&mut self) { - let ( - show_cursor, - role_label_mode, - syntax_highlighting, - render_markdown, - show_timestamps, - accessibility, - theme_name, - ) = { - let controller = self.controller_lock(); - let guard = controller.config(); - ( - guard.ui.show_cursor_outside_insert, - guard.ui.role_label_mode, - guard.ui.syntax_highlighting, - guard.ui.render_markdown, - guard.ui.show_timestamps, - guard.ui.accessibility.clone(), - guard.ui.theme.clone(), - ) - }; - self.show_cursor_outside_insert = show_cursor; - self.syntax_highlighting = syntax_highlighting; - self.render_markdown = render_markdown; - self.show_message_timestamps = show_timestamps; - self.controller_lock().set_role_label_mode(role_label_mode); - let base_theme_changed = self.base_theme_name != theme_name; - if base_theme_changed { - self.base_theme_name = theme_name; - } - let high_changed = self.accessibility_high_contrast != accessibility.high_contrast; - let reduced_changed = self.accessibility_reduced_chrome != accessibility.reduced_chrome; - self.accessibility_high_contrast = accessibility.high_contrast; - self.accessibility_reduced_chrome = accessibility.reduced_chrome; - - let theme_requires_reset = - !self.accessibility_high_contrast && self.theme.name != self.base_theme_name; - if base_theme_changed || high_changed || theme_requires_reset { - self.reapply_active_theme(); - } - - if reduced_changed && !high_changed && !base_theme_changed { - self.message_line_cache.clear(); - } - } - - pub fn cursor_should_be_visible(&self) -> bool { - if matches!(self.mode, InputMode::Editing) { - true - } else { - self.show_cursor_outside_insert - } - } - - pub fn should_highlight_code(&self) -> bool { - true - } - - pub fn render_markdown_enabled(&self) -> bool { - self.render_markdown - } - - pub fn set_render_markdown(&mut self, enabled: bool) { - if self.render_markdown == enabled { - self.status = if enabled { - "Markdown rendering already enabled".to_string() - } else { - "Markdown rendering already disabled".to_string() - }; - self.error = None; - return; - } - - self.render_markdown = enabled; - self.message_line_cache.clear(); - - self.with_config_mut(|cfg| { - cfg.ui.render_markdown = enabled; - }); - - if let Err(err) = self.with_config(config::save_config) { - self.error = Some(format!("Failed to save config: {}", err)); - } else { - self.error = None; - } - - self.status = if enabled { - "Markdown rendering enabled".to_string() - } else { - "Markdown rendering disabled".to_string() - }; - } - - pub(crate) fn render_message_lines_cached( - &mut self, - message_index: usize, - ctx: MessageRenderContext<'_>, - ) -> Vec> { - let MessageRenderContext { - formatter, - role_label_mode, - body_width, - card_width, - is_streaming, - loading_indicator, - theme, - syntax_highlighting, - render_markdown, - } = ctx; - let (message_id, role, raw_content, timestamp, tool_calls, tool_result_id, attachments) = { - let conversation = self.conversation(); - let message = &conversation.messages[message_index]; - ( - message.id, - message.role.clone(), - message.content.clone(), - message.timestamp, - message.tool_calls.clone(), - message - .metadata - .get("tool_call_id") - .and_then(|value| value.as_str()) - .map(|value| value.to_string()), - message.attachments.clone(), - ) - }; - - let display_content = if matches!(role, Role::Assistant) { - formatter.extract_thinking(&raw_content).0 - } else if matches!(role, Role::Tool) { - format_tool_output(&raw_content) - } else { - raw_content - }; - - let normalized_content = display_content.replace("\r\n", "\n"); - let trimmed = normalized_content.trim(); - let content = trimmed.to_string(); - let segments = parse_message_segments(trimmed, render_markdown); - let tool_signature = tool_calls - .as_ref() - .map(|calls| { - let mut names: Vec<&str> = calls.iter().map(|call| call.name.as_str()).collect(); - names.sort_unstable(); - names.join("|") - }) - .unwrap_or_default(); - let attachment_signature = attachments - .iter() - .map(|attachment| attachment.id.to_string()) - .collect::>() - .join("|"); - let content_hash = - Self::message_content_hash(&role, &content, &tool_signature, &attachment_signature); - - if !is_streaming - && let Some(entry) = self.message_line_cache.get(&message_id) - && entry.wrap_width == card_width - && entry.role_label_mode == role_label_mode - && entry.syntax_highlighting == syntax_highlighting - && entry.render_markdown == render_markdown - && entry.theme_name == theme.name - && entry.show_timestamps == self.show_message_timestamps - && entry.metrics.body_width == body_width - && entry.metrics.card_width == card_width - && entry.content_hash == content_hash - { - return entry.lines.clone(); - } - - let mut rendered: Vec> = Vec::new(); - let content_style = Self::content_style(theme, &role); - let mut indicator_target: Option = None; - - let indicator_span = if is_streaming { - Some(Span::styled( - format!(" {}", streaming_indicator_symbol(loading_indicator)), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.cursor)), - )) - } else { - None - }; - - let mut append_segments = |segments: &[MessageSegment], - indent: &str, - available_width: usize, - indicator_target: &mut Option, - code_width: usize| { - if segments.is_empty() { - let line_text = if indent.is_empty() { - String::new() - } else { - indent.to_string() - }; - rendered.push(Line::from(vec![Span::styled(line_text, content_style)])); - *indicator_target = Some(rendered.len() - 1); - return; - } - - for segment in segments { - match segment { - MessageSegment::Text { lines } => { - if render_markdown { - let block = lines.join("\n"); - let markdown_lines = render_markdown_lines( - &block, - indent, - available_width, - content_style, - ); - for line in markdown_lines { - rendered.push(line); - *indicator_target = Some(rendered.len() - 1); - } - } else { - for line_text in lines { - let mut chunks = wrap_unicode(line_text.as_str(), available_width); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - let mut spans: Vec> = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), content_style)); - } - - let inline_spans = - inline_code_spans_from_text(&chunk, theme, content_style); - spans.extend(inline_spans); - - rendered.push(Line::from(spans)); - *indicator_target = Some(rendered.len() - 1); - } - } - } - } - MessageSegment::CodeBlock { language, lines } => { - append_code_block_lines( - &mut rendered, - indent, - code_width, - language.as_deref(), - lines, - theme, - syntax_highlighting, - indicator_target, - ); - } - } - } - }; - - match role_label_mode { - RoleLabelDisplay::Above => { - let indent = " "; - let indent_width = UnicodeWidthStr::width(indent); - let available_width = body_width.saturating_sub(indent_width).max(1); - append_segments( - &segments, - indent, - available_width, - &mut indicator_target, - body_width.saturating_sub(indent_width), - ); - } - RoleLabelDisplay::Inline | RoleLabelDisplay::None => { - let indent = ""; - let available_width = body_width.max(1); - append_segments( - &segments, - indent, - available_width, - &mut indicator_target, - body_width, - ); - } - } - if let Some(indicator) = indicator_span { - if let Some(idx) = indicator_target { - if let Some(line) = rendered.get_mut(idx) { - line.spans.push(indicator); - } else { - rendered.push(Line::from(vec![indicator])); - } - } else { - rendered.push(Line::from(vec![indicator])); - } - } - - if !attachments.is_empty() { - if !rendered.is_empty() { - rendered.push(Line::from(vec![Span::raw("")])); - } - for (idx, attachment) in attachments.iter().enumerate() { - let summary = Self::summarize_attachment(attachment); - let header_line = Line::from(vec![ - Span::styled( - "┆ ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ), - Span::styled( - format!("Attachment {}:", idx + 1), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(summary, content_style), - ]); - rendered.push(header_line); - - let preview_lines = self.generate_attachment_preview_lines(attachment); - for preview in preview_lines - .into_iter() - .take(ATTACHMENT_INLINE_PREVIEW_LINES) - { - rendered.push(Line::from(vec![Span::styled( - format!(" {}", preview), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )])); - } - } - } - - let markers = - Self::message_tool_markers(&role, tool_calls.as_ref(), tool_result_id.as_deref()); - let formatted_timestamp = if self.show_message_timestamps { - Some(Self::format_message_timestamp(timestamp)) - } else { - None - }; - - let card_lines = Self::wrap_message_in_card( - rendered, - &role, - formatted_timestamp.as_deref(), - &markers, - card_width, - theme, - ); - let metrics = MessageLayoutMetrics { - line_count: card_lines.len(), - body_width, - card_width, - }; - debug_assert_eq!(metrics.line_count, card_lines.len()); - - if !is_streaming { - self.message_line_cache.insert( - message_id, - MessageCacheEntry { - theme_name: theme.name.clone(), - wrap_width: card_width, - role_label_mode, - syntax_highlighting, - render_markdown, - show_timestamps: self.show_message_timestamps, - content_hash, - lines: card_lines.clone(), - metrics: metrics.clone(), - }, - ); - } - - card_lines - } - - fn message_tool_markers( - role: &Role, - tool_calls: Option<&Vec>, - tool_result_id: Option<&str>, - ) -> Vec { - let mut markers = Vec::new(); - - match role { - Role::Assistant => { - if let Some(calls) = tool_calls { - const MAX_VISIBLE: usize = 3; - for call in calls.iter().take(MAX_VISIBLE) { - markers.push(format!("[Tool: {}]", call.name)); - } - if calls.len() > MAX_VISIBLE { - markers.push(format!("[+{}]", calls.len() - MAX_VISIBLE)); - } - } - } - Role::Tool => { - if let Some(id) = tool_result_id { - markers.push(format!("[Result: {id}]")); - } else { - markers.push("[Result]".to_string()); - } - } - _ => {} - } - - markers - } - - fn format_message_timestamp(timestamp: SystemTime) -> String { - let datetime: DateTime = timestamp.into(); - datetime.format("%H:%M").to_string() - } - - fn wrap_message_in_card( - mut lines: Vec>, - role: &Role, - timestamp: Option<&str>, - markers: &[String], - card_width: usize, - theme: &Theme, - ) -> Vec> { - if card_width < MIN_MESSAGE_CARD_WIDTH { - return Self::wrap_message_compact(lines, role, timestamp, markers, theme); - } - - let inner_width = card_width.saturating_sub(4).max(1); - let mut card_lines = Vec::with_capacity(lines.len() + 2); - - card_lines.push(Self::build_card_header( - role, timestamp, markers, card_width, theme, - )); - - if lines.is_empty() { - lines.push(Line::from(String::new())); - } - - for line in lines { - card_lines.push(Self::wrap_card_body_line(line, inner_width, theme, role)); - } - - card_lines.push(Self::build_card_footer(card_width, theme, role)); - card_lines - } - - fn wrap_message_compact( - lines: Vec>, - role: &Role, - timestamp: Option<&str>, - markers: &[String], - theme: &Theme, - ) -> Vec> { - let role_style = Self::role_style(theme, role).add_modifier(Modifier::BOLD); - let meta_style = - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)); - let tool_style = Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.tool_output)) - .add_modifier(Modifier::BOLD); - - let (emoji, title) = role_label_parts(role); - let mut header_spans: Vec> = - vec![Span::styled(format!("{emoji} {title}"), role_style)]; - - if let Some(ts) = timestamp { - header_spans.push(Span::styled(" · ".to_string(), meta_style)); - header_spans.push(Span::styled(ts.to_string(), meta_style)); - } - - for marker in markers { - header_spans.push(Span::styled(" ".to_string(), meta_style)); - header_spans.push(Span::styled(marker.clone(), tool_style)); - } - - let mut compact_lines = Vec::with_capacity(lines.len() + 2); - compact_lines.push(Line::from(header_spans)); - - if lines.is_empty() { - compact_lines.push(Line::from(vec![Span::raw("")])); - } else { - compact_lines.extend(lines); - } - - compact_lines.push(Line::from(vec![Span::raw("")])); - compact_lines - } - - fn build_card_header( - role: &Role, - timestamp: Option<&str>, - markers: &[String], - card_width: usize, - theme: &Theme, - ) -> Line<'static> { - let border_style = Self::message_border_style(theme, role); - let role_style = Self::role_style(theme, role).add_modifier(Modifier::BOLD); - let meta_style = - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)); - let tool_style = Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.tool_output)) - .add_modifier(Modifier::BOLD); - - let mut spans: Vec> = Vec::new(); - spans.push(Span::styled("┌", border_style)); - let mut consumed = 1usize; - - spans.push(Span::styled(" ", border_style)); - consumed += 1; - - let (emoji, title) = role_label_parts(role); - let label_text = format!("{emoji} {title}"); - let label_width = UnicodeWidthStr::width(label_text.as_str()); - spans.push(Span::styled(label_text, role_style)); - consumed += label_width; - - if let Some(ts) = timestamp { - let separator = " — "; - let separator_width = UnicodeWidthStr::width(separator); - let ts_width = UnicodeWidthStr::width(ts); - if consumed + separator_width + ts_width + 1 < card_width { - spans.push(Span::styled(separator.to_string(), border_style)); - consumed += separator_width; - spans.push(Span::styled(ts.to_string(), meta_style)); - consumed += ts_width; - } - } - - for marker in markers { - let spacer_width = 2usize; - let marker_width = UnicodeWidthStr::width(marker.as_str()); - if consumed + spacer_width + marker_width + 1 > card_width { - break; - } - spans.push(Span::styled(" ".to_string(), border_style)); - spans.push(Span::styled(marker.clone(), tool_style)); - consumed += spacer_width + marker_width; - } - - if consumed + 1 < card_width { - spans.push(Span::styled(" ", border_style)); - consumed += 1; - } - - let remaining = card_width.saturating_sub(consumed + 1); - if remaining > 0 { - spans.push(Span::styled("─".repeat(remaining), border_style)); - } - - spans.push(Span::styled("┐", border_style)); - Line::from(spans) - } - - fn build_card_footer(card_width: usize, theme: &Theme, role: &Role) -> Line<'static> { - let border_style = Self::message_border_style(theme, role); - let mut spans = Vec::new(); - spans.push(Span::styled("└", border_style)); - let horizontal = card_width.saturating_sub(2); - if horizontal > 0 { - spans.push(Span::styled("─".repeat(horizontal), border_style)); - } - spans.push(Span::styled("┘", border_style)); - Line::from(spans) - } - - fn wrap_card_body_line( - line: Line<'static>, - inner_width: usize, - theme: &Theme, - role: &Role, - ) -> Line<'static> { - let border_style = Self::message_border_style(theme, role); - let mut spans = Vec::new(); - spans.push(Span::styled("│ ", border_style)); - - let content_width = Self::line_display_width(&line).min(inner_width); - let mut body_spans = line.spans; - spans.append(&mut body_spans); - - if content_width < inner_width { - spans.push(Span::styled( - " ".repeat(inner_width - content_width), - Style::default(), - )); - } - - spans.push(Span::styled(" │", border_style)); - Line::from(spans) - } - - fn build_card_header_plain( - role: &Role, - timestamp: Option<&str>, - markers: &[String], - card_width: usize, - ) -> String { - let mut result = String::new(); - let mut consumed = 0usize; - - result.push('┌'); - consumed += 1; - - result.push(' '); - consumed += 1; - - let (emoji, title) = role_label_parts(role); - let label_text = format!("{emoji} {title}"); - result.push_str(&label_text); - consumed += UnicodeWidthStr::width(label_text.as_str()); - - if let Some(ts) = timestamp { - let separator = " — "; - let separator_width = UnicodeWidthStr::width(separator); - let ts_width = UnicodeWidthStr::width(ts); - if consumed + separator_width + ts_width + 1 < card_width { - result.push_str(separator); - result.push_str(ts); - consumed += separator_width + ts_width; - } - } - - for marker in markers { - let spacer_width = 2usize; - let marker_width = UnicodeWidthStr::width(marker.as_str()); - if consumed + spacer_width + marker_width + 1 >= card_width { - break; - } - result.push_str(" "); - result.push_str(marker); - consumed += spacer_width + marker_width; - } - - let remaining = card_width.saturating_sub(consumed + 1); - if remaining > 0 { - result.push_str(&"─".repeat(remaining)); - } - - result.push('┐'); - result - } - - fn wrap_card_body_line_plain(line: &str, inner_width: usize) -> String { - let mut result = String::from("│ "); - result.push_str(line); - let content_width = UnicodeWidthStr::width(line); - if content_width < inner_width { - result.push_str(&" ".repeat(inner_width - content_width)); - } - result.push_str(" │"); - result - } - - fn build_card_footer_plain(card_width: usize) -> String { - let mut result = String::new(); - result.push('└'); - let horizontal = card_width.saturating_sub(2); - if horizontal > 0 { - result.push_str(&"─".repeat(horizontal)); - } - result.push('┘'); - result - } - - fn line_display_width(line: &Line<'_>) -> usize { - line.spans - .iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum() - } - - pub fn apply_chat_scrollback_trim(&mut self, removed: usize, remaining: usize) { - if removed == 0 { - self.chat_line_offset = 0; - self.chat_cursor.0 = self.chat_cursor.0.min(remaining.saturating_sub(1)); - return; - } - - self.chat_line_offset = removed; - self.auto_scroll.scroll = self.auto_scroll.scroll.saturating_sub(removed); - self.auto_scroll.content_len = remaining; - - if let Some((row, _)) = &mut self.visual_start { - if *row < removed { - self.visual_start = None; - } else { - *row -= removed; - } - } - - if let Some((row, _)) = &mut self.visual_end { - if *row < removed { - self.visual_end = None; - } else { - *row -= removed; - } - } - - self.chat_cursor.0 = self.chat_cursor.0.saturating_sub(removed); - if remaining == 0 { - self.chat_cursor = (0, 0); - } else if self.chat_cursor.0 >= remaining { - self.chat_cursor.0 = remaining - 1; - } - - let max_scroll = remaining.saturating_sub(self.viewport_height); - if self.auto_scroll.scroll > max_scroll { - self.auto_scroll.scroll = max_scroll; - } - - if self.auto_scroll.stick_to_bottom { - self.auto_scroll.on_viewport(self.viewport_height); - } - - self.update_new_message_alert_after_scroll(); - } - - pub fn set_theme(&mut self, theme: Theme) { - self.theme = theme; - self.message_line_cache.clear(); - } - - pub fn switch_theme(&mut self, theme_name: &str) -> Result<()> { - if let Some(theme) = owlen_core::get_theme(theme_name) { - self.base_theme_name = theme_name.to_string(); - self.with_config_mut(|cfg| { - cfg.ui.theme = self.base_theme_name.clone(); - }); - - if let Err(err) = self.with_config(config::save_config) { - let message = format!("Failed to save theme config: {}", err); - self.error = Some(message.clone()); - return Err(anyhow!(message)); - } - - if self.accessibility_high_contrast { - self.reapply_active_theme(); - self.status = format!( - "Saved base theme '{}' (high-contrast override active)", - theme_name - ); - self.error = None; - return Ok(()); - } - - self.set_theme(theme); - self.status = format!("Switched to theme: {}", theme_name); - self.error = None; - Ok(()) - } else { - self.error = Some(format!("Theme '{}' not found", theme_name)); - Err(anyhow::anyhow!("Theme '{}' not found", theme_name)) - } - } - - fn reapply_active_theme(&mut self) { - let target_theme = if self.accessibility_high_contrast { - HIGH_CONTRAST_THEME_NAME.to_string() - } else { - self.base_theme_name.clone() - }; - - let theme = owlen_core::get_theme(&target_theme).unwrap_or_else(|| { - eprintln!( - "Warning: Theme '{}' not found, using default fallback", - target_theme - ); - if !self.accessibility_high_contrast { - self.base_theme_name = Theme::default().name.clone(); - } - Theme::default() - }); - self.set_theme(theme); - } - - fn persist_accessibility_flags(&mut self) -> Result<()> { - self.with_config_mut(|cfg| { - cfg.ui.accessibility.high_contrast = self.accessibility_high_contrast; - cfg.ui.accessibility.reduced_chrome = self.accessibility_reduced_chrome; - }); - self.with_config(config::save_config) - } - - fn accessibility_summary(high: bool, reduced: bool) -> String { - let high_state = if high { "on" } else { "off" }; - let reduced_state = if reduced { "on" } else { "off" }; - format!("Accessibility · High contrast {high_state} · Reduced chrome {reduced_state}") - } - - fn set_accessibility_modes(&mut self, high_contrast: bool, reduced_chrome: bool) { - let high_changed = self.accessibility_high_contrast != high_contrast; - let reduced_changed = self.accessibility_reduced_chrome != reduced_chrome; - - if !high_changed && !reduced_changed { - self.status = Self::accessibility_summary( - self.accessibility_high_contrast, - self.accessibility_reduced_chrome, - ); - self.error = None; - return; - } - - self.accessibility_high_contrast = high_contrast; - self.accessibility_reduced_chrome = reduced_chrome; - - if high_changed { - self.reapply_active_theme(); - } else if !self.accessibility_high_contrast { - // Ensure base theme is applied if we toggled away from high-contrast elsewhere. - self.reapply_active_theme(); - } - - if reduced_changed { - self.message_line_cache.clear(); - } - - match self.persist_accessibility_flags() { - Ok(_) => { - self.status = Self::accessibility_summary( - self.accessibility_high_contrast, - self.accessibility_reduced_chrome, - ); - self.error = None; - self.push_toast( - ToastLevel::Info, - Self::accessibility_summary( - self.accessibility_high_contrast, - self.accessibility_reduced_chrome, - ), - ); - } - Err(err) => { - let message = format!("Failed to persist accessibility settings: {}", err); - self.error = Some(message.clone()); - self.push_toast(ToastLevel::Error, message); - } - } - } - - fn set_high_contrast_mode(&mut self, enabled: bool) { - if self.accessibility_high_contrast == enabled { - self.status = if enabled { - "High-contrast mode already enabled".to_string() - } else { - "High-contrast mode already disabled".to_string() - }; - self.error = None; - return; - } - - self.set_accessibility_modes(enabled, self.accessibility_reduced_chrome); - } - - fn set_reduced_chrome_mode(&mut self, enabled: bool) { - if self.accessibility_reduced_chrome == enabled { - self.status = if enabled { - "Reduced chrome already enabled".to_string() - } else { - "Reduced chrome already disabled".to_string() - }; - self.error = None; - return; - } - - self.set_accessibility_modes(self.accessibility_high_contrast, enabled); - } - - fn cycle_accessibility_presets(&mut self) { - let next = match ( - self.accessibility_high_contrast, - self.accessibility_reduced_chrome, - ) { - (false, false) => (true, false), - (true, false) => (true, true), - (true, true) => (false, false), - (false, true) => (false, false), - }; - self.set_accessibility_modes(next.0, next.1); - } - - fn handle_accessibility_command(&mut self, args: &[&str]) -> Result<()> { - if args.is_empty() { - self.cycle_accessibility_presets(); - return Ok(()); - } - - let mut iter = args.iter().map(|s| s.to_lowercase()); - match iter.next().as_deref() { - Some("status") => { - self.status = self.accessibility_status(); - self.error = None; - } - Some("cycle") | Some("next") => { - self.cycle_accessibility_presets(); - } - Some("reset") | Some("default") => { - self.set_accessibility_modes(false, false); - } - Some("high") | Some("high-contrast") => { - if let Some(value) = iter.next() { - match value.as_str() { - "on" => self.set_high_contrast_mode(true), - "off" => self.set_high_contrast_mode(false), - "toggle" => self.set_high_contrast_mode(!self.is_high_contrast_enabled()), - other => { - self.error = Some(format!( - "Unknown accessibility value '{}'. Use on|off|toggle.", - other - )); - self.status = "Usage: :accessibility high ".to_string(); - } - } - } else { - self.set_high_contrast_mode(!self.is_high_contrast_enabled()); - } - } - Some("reduced") | Some("reduced-chrome") | Some("chrome") => { - if let Some(value) = iter.next() { - match value.as_str() { - "on" => self.set_reduced_chrome_mode(true), - "off" => self.set_reduced_chrome_mode(false), - "toggle" => self.set_reduced_chrome_mode(!self.is_reduced_chrome()), - other => { - self.error = Some(format!( - "Unknown accessibility value '{}'. Use on|off|toggle.", - other - )); - self.status = - "Usage: :accessibility reduced ".to_string(); - } - } - } else { - self.set_reduced_chrome_mode(!self.is_reduced_chrome()); - } - } - Some(other) => { - self.error = Some(format!("Unknown accessibility subcommand '{other}'.")); - self.status = - "Usage: :accessibility [cycle|status|reset|high |reduced ]" - .to_string(); - } - None => {} - } - - Ok(()) - } - - fn focus_sequence(&self) -> Vec { - let mut order = Vec::new(); - if !self.file_panel_collapsed { - order.push(FocusedPanel::Files); - } - order.push(FocusedPanel::Chat); - if self.should_show_code_view() { - order.push(FocusedPanel::Code); - } - if self.current_thinking.is_some() { - order.push(FocusedPanel::Thinking); - } - order.push(FocusedPanel::Input); - order - } - - fn ensure_focus_valid(&mut self) { - let order = self.focus_sequence(); - if order.is_empty() { - self.focused_panel = FocusedPanel::Chat; - } else if !order.contains(&self.focused_panel) { - self.focused_panel = order[0]; - } - if let FocusedPanel::Thinking = self.focused_panel { - // Ensure the vertical split favours thinking panel if chat collapsed entirely - if let Some(tab) = self.workspace_mut().active_tab_mut() { - tab.root.ensure_ratio_bounds(); - } - } - } - - pub fn cycle_focus_forward(&mut self) { - let order = self.focus_sequence(); - if order.is_empty() { - self.focused_panel = FocusedPanel::Chat; - return; - } - if !order.contains(&self.focused_panel) { - self.focused_panel = order[0]; - } - let current_index = order - .iter() - .position(|panel| *panel == self.focused_panel) - .unwrap_or(0); - let next_index = (current_index + 1) % order.len(); - self.focused_panel = order[next_index]; - } - - pub fn cycle_focus_backward(&mut self) { - let order = self.focus_sequence(); - if order.is_empty() { - self.focused_panel = FocusedPanel::Chat; - return; - } - if !order.contains(&self.focused_panel) { - self.focused_panel = order[0]; - } - let current_index = order - .iter() - .position(|panel| *panel == self.focused_panel) - .unwrap_or(0); - let prev_index = if current_index == 0 { - order.len().saturating_sub(1) - } else { - current_index - 1 - }; - self.focused_panel = order[prev_index]; - } - - pub fn focus_panel(&mut self, target: FocusedPanel) -> bool { - match target { - FocusedPanel::Files => { - if self.file_panel_collapsed { - self.expand_file_panel(); - if self.file_panel_collapsed { - return false; - } - } - } - FocusedPanel::Code => { - if !self.should_show_code_view() { - return false; - } - } - FocusedPanel::Thinking => { - if self.current_thinking.is_none() && self.agent_actions.is_none() { - return false; - } - } - FocusedPanel::Chat | FocusedPanel::Input => {} - } - - let order = self.focus_sequence(); - if !order.contains(&target) { - return false; - } - - self.focused_panel = target; - self.ensure_focus_valid(); - true - } - - /// Sync textarea content to input buffer - fn sync_textarea_to_buffer(&mut self) { - let text = self.textarea.lines().join("\n"); - self.with_input_buffer_mut(|buffer| buffer.set_text(text.clone())); - let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::DraftChanged { - content: text, - })); - } - - /// Sync input buffer content to textarea - fn sync_buffer_to_textarea(&mut self) { - let text = self.input_buffer_text(); - let lines: Vec = text.lines().map(|s| s.to_string()).collect(); - self.textarea = TextArea::new(lines); - configure_textarea_defaults(&mut self.textarea); - let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::DraftChanged { - content: text, - })); - } - - fn apply_app_event(&mut self, event: AppEvent) -> Vec { - mvu::update(&mut self.mvu_model, event) - } - - async fn handle_app_effects(&mut self, effects: Vec) -> Result<()> { - let mut pending = effects; - while let Some(effect) = pending.pop() { - match effect { - AppEffect::SetStatus(message) => { - self.error = Some(message.clone()); - self.status = message; - } - AppEffect::RequestSubmit => { - let outcome = self.process_composer_submission().await?; - let mut follow_up = self.apply_app_event(AppEvent::Composer( - ComposerEvent::SubmissionHandled { result: outcome }, - )); - pending.append(&mut follow_up); - - match outcome { - SubmissionOutcome::MessageSent | SubmissionOutcome::CommandExecuted => { - self.sync_buffer_to_textarea(); - self.set_input_mode(InputMode::Normal); - } - SubmissionOutcome::Failed => { - self.sync_buffer_to_textarea(); - } - } - } - AppEffect::ResolveToolConsent { request_id, scope } => { - let resolution = self - .controller_lock() - .resolve_tool_consent(request_id, scope)?; - self.apply_tool_consent_resolution(resolution)?; - } - } - } - - Ok(()) - } - - async fn process_composer_submission(&mut self) -> Result { - match self.process_slash_submission().await? { - SlashOutcome::NotCommand => { - self.send_user_message_and_request_response(); - Ok(SubmissionOutcome::MessageSent) - } - SlashOutcome::Consumed => Ok(SubmissionOutcome::CommandExecuted), - SlashOutcome::Error => Ok(SubmissionOutcome::Failed), - } - } - - async fn try_execute_command(&mut self, key: &KeyEvent) -> Result { - match self.keymap.step(self.mode, key, &mut self.keymap_state) { - KeymapEventResult::Matched(command) => { - if self.execute_command(command).await? { - return Ok(true); - } - } - KeymapEventResult::Pending => { - self.update_pending_sequence_status(); - return Ok(true); - } - KeymapEventResult::NoMatch => {} - } - - Ok(false) - } - - fn update_pending_sequence_status(&mut self) { - if self.keymap_state.matches_sequence(&["Ctrl+W"]) { - self.status = "Split layout: press s for horizontal, v for vertical".to_string(); - self.error = None; - } else if self.keymap_state.matches_sequence(&["Ctrl+K"]) { - if self.show_model_info && self.model_info_viewport_height > 0 { - self.model_info_panel.scroll_up(); - } - self.status = "Pane focus pending — use ←/→/↑/↓".to_string(); - self.error = None; - } - } - - fn handle_emacs_editing_key(&mut self, key: &KeyEvent) -> bool { - if !matches!(self.focused_panel, FocusedPanel::Input) { - return false; - } - - use crossterm::event::{KeyCode, KeyModifiers}; - - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - let alt = key.modifiers.contains(KeyModifiers::ALT); - - match key.code { - KeyCode::Char(ch) => match (ch.to_ascii_lowercase(), ctrl, alt) { - ('y', true, false) => self.emacs_yank(), - ('w', true, false) => self.emacs_kill_word_back(), - ('w', false, true) => self.emacs_copy_word_back(), - ('k', true, false) => self.emacs_kill_line(), - _ => false, - }, - _ => false, - } - } - - fn emacs_yank(&mut self) -> bool { - if self.clipboard.is_empty() { - self.status = "Kill ring is empty".to_string(); - return true; - } - - if self.textarea.insert_str(&self.clipboard) { - self.sync_textarea_to_buffer(); - self.status = format!("Yanked {} chars", self.clipboard.len()); - self.error = None; - } else { - self.status = "Unable to insert clipboard contents".to_string(); - } - true - } - - fn emacs_kill_word_back(&mut self) -> bool { - self.textarea.cancel_selection(); - self.textarea.start_selection(); - self.textarea.move_cursor(CursorMove::WordBack); - - let killed = if self.textarea.cut() { - let grabbed = self.textarea.yank_text(); - if grabbed.is_empty() { - None - } else { - Some(grabbed) - } - } else { - None - }; - - if let Some(grabbed) = killed { - self.clipboard = grabbed; - self.status = format!("Killed {} chars", self.clipboard.len()); - self.error = None; - self.sync_textarea_to_buffer(); - } else { - self.status = "Nothing to kill".to_string(); - } - - self.textarea.cancel_selection(); - true - } - - fn emacs_copy_word_back(&mut self) -> bool { - self.textarea.cancel_selection(); - self.textarea.start_selection(); - self.textarea.move_cursor(CursorMove::WordBack); - self.textarea.copy(); - let copied = self.textarea.yank_text(); - self.textarea.cancel_selection(); - self.textarea.move_cursor(CursorMove::WordForward); - - if copied.is_empty() { - self.status = "Nothing to copy".to_string(); - } else { - self.clipboard = copied; - self.status = format!("Copied {} chars", self.clipboard.len()); - self.error = None; - } - true - } - - fn emacs_kill_line(&mut self) -> bool { - self.textarea.cancel_selection(); - let start_cursor = self.textarea.cursor(); - self.textarea.start_selection(); - self.textarea.move_cursor(CursorMove::End); - if self.textarea.cursor() == start_cursor { - self.textarea.move_cursor(CursorMove::Forward); - } - - let killed = if self.textarea.cut() { - let grabbed = self.textarea.yank_text(); - if grabbed.is_empty() { - None - } else { - Some(grabbed) - } - } else { - None - }; - - if let Some(grabbed) = killed { - self.clipboard = grabbed; - self.status = format!("Killed {} chars", self.clipboard.len()); - self.error = None; - self.sync_textarea_to_buffer(); - } else { - self.status = "Nothing to kill".to_string(); - } - - self.textarea.cancel_selection(); - true - } - - async fn execute_command(&mut self, command: AppCommand) -> Result { - match command { - AppCommand::OpenModelPicker(filter) => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - if let Err(err) = self.show_model_picker(filter).await { - self.error = Some(err.to_string()); - } - Ok(true) - } - AppCommand::OpenCommandPalette | AppCommand::EnterCommandMode => { - if !matches!( - self.mode, - InputMode::Normal | InputMode::Editing | InputMode::Command - ) { - return Ok(false); - } - self.set_input_mode(InputMode::Command); - self.command_palette.clear(); - self.command_palette.ensure_suggestions(); - self.status = ":".to_string(); - self.error = None; - Ok(true) - } - AppCommand::OpenProviderSwitcher => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - if let Err(err) = self.show_model_picker(None).await { - self.error = Some(err.to_string()); - } else if matches!(self.mode, InputMode::ProviderSelection) { - self.status = "Select a provider to activate".to_string(); - self.error = None; - } - Ok(true) - } - AppCommand::CycleFocusForward => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - self.cycle_focus_forward(); - self.status = format!("Focus: {}", Self::panel_label(self.focused_panel)); - self.error = None; - Ok(true) - } - AppCommand::CycleFocusBackward => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - self.cycle_focus_backward(); - self.status = format!("Focus: {}", Self::panel_label(self.focused_panel)); - self.error = None; - Ok(true) - } - AppCommand::FocusPanel(target) => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - if self.focus_panel(target) { - self.status = match target { - FocusedPanel::Input => "Focus: Input — press i to edit".to_string(), - _ => format!("Focus: {}", Self::panel_label(target)), - }; - self.error = None; - } else { - self.status = match target { - FocusedPanel::Files => { - if self.is_code_mode() { - "Files panel is collapsed — use :files to reopen".to_string() - } else { - "Unable to focus Files panel".to_string() - } - } - FocusedPanel::Code => "Open a file to focus the code workspace".to_string(), - FocusedPanel::Thinking => "No reasoning panel to focus yet".to_string(), - FocusedPanel::Chat => "Unable to focus Chat panel".to_string(), - FocusedPanel::Input => "Unable to focus Input panel".to_string(), - }; - } - Ok(true) - } - AppCommand::ComposerSubmit => { - if !matches!(self.mode, InputMode::Editing) { - return Ok(false); - } - self.sync_textarea_to_buffer(); - let effects = self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit)); - self.handle_app_effects(effects).await?; - Ok(true) - } - AppCommand::ToggleDebugLog => { - self.toggle_debug_log_panel(); - Ok(true) - } - AppCommand::SetKeymap(profile) => { - if profile.is_builtin() { - self.switch_keymap_profile(profile).await?; - } - Ok(true) - } - AppCommand::JumpToTop => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - self.jump_to_top(); - self.status = "Jumped to top".to_string(); - self.error = None; - Ok(true) - } - AppCommand::JumpToBottom => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - self.jump_to_bottom(); - self.status = "Jumped to bottom".to_string(); - self.error = None; - Ok(true) - } - AppCommand::ExpandFilePanel => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - if self.focus_panel(FocusedPanel::Files) { - self.status = "Files panel focused".to_string(); - self.error = None; - } else { - self.status = "Unable to focus Files panel".to_string(); - } - Ok(true) - } - AppCommand::ToggleHiddenFiles => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - if matches!(self.focused_panel, FocusedPanel::Files) { - self.toggle_hidden_files(); - } else { - self.status = "Toggle hidden files from the Files panel".to_string(); - } - Ok(true) - } - AppCommand::SplitPaneHorizontal => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - self.split_active_pane(SplitAxis::Horizontal); - Ok(true) - } - AppCommand::SplitPaneVertical => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - self.split_active_pane(SplitAxis::Vertical); - Ok(true) - } - AppCommand::ClearInputBuffer => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - self.controller_lock().input_buffer_mut().clear(); - self.textarea = TextArea::default(); - configure_textarea_defaults(&mut self.textarea); - self.status = "Input buffer cleared".to_string(); - self.error = None; - Ok(true) - } - AppCommand::MoveWorkspaceFocus(direction) => { - if !matches!(self.mode, InputMode::Normal) { - return Ok(false); - } - self.handle_workspace_focus_move(direction); - Ok(true) - } - } - } - - fn panel_label(panel: FocusedPanel) -> &'static str { - match panel { - FocusedPanel::Files => "Files", - FocusedPanel::Chat => "Chat", - FocusedPanel::Thinking => "Thinking", - FocusedPanel::Input => "Input", - FocusedPanel::Code => "Code", - } - } - - pub fn adjust_vertical_split(&mut self, delta: f32) { - if let Some(tab) = self.workspace_mut().active_tab_mut() { - tab.root.nudge_ratio(delta); - } - } - - async fn process_slash_submission(&mut self) -> Result { - let raw = { - let controller = self.controller_lock_async().await; - controller.input_buffer().text().to_string() - }; - if raw.trim().is_empty() { - return Ok(SlashOutcome::NotCommand); - } - - match slash::parse(&raw) { - Ok(None) => Ok(SlashOutcome::NotCommand), - Ok(Some(command)) => match self.execute_slash_command(command).await { - Ok(()) => { - { - let mut controller = self.controller_lock_async().await; - controller - .input_buffer_mut() - .push_history_entry(raw.clone()); - controller.input_buffer_mut().clear(); - } - Ok(SlashOutcome::Consumed) - } - Err(err) => { - self.error = Some(err.to_string()); - self.status = "Slash command failed".to_string(); - { - let mut controller = self.controller_lock_async().await; - controller.input_buffer_mut().set_text(raw); - } - Ok(SlashOutcome::Error) - } - }, - Err(err) => { - self.error = Some(err.to_string()); - self.status = "Slash command error".to_string(); - Ok(SlashOutcome::Error) - } - } - } - - async fn execute_slash_command(&mut self, command: SlashCommand) -> Result<()> { - match command { - SlashCommand::Summarize { count } => { - let prompt = if let Some(count) = count { - format!( - "Summarize the last {count} messages in this conversation. Highlight key decisions, open questions, and follow-up tasks." - ) - } else { - "Summarize the conversation so far, calling out major decisions, blockers, and immediate next steps.".to_string() - }; - self.status = "Summarizing conversation...".to_string(); - self.dispatch_user_prompt(prompt); - } - SlashCommand::Explain { snippet } => { - let prompt = format!( - "Explain the following code snippet. Cover what it does and call out any potential issues or improvements:\n```\n{}\n```", - snippet - ); - self.status = "Explaining snippet...".to_string(); - self.dispatch_user_prompt(prompt); - } - SlashCommand::Refactor { path } => { - let trimmed = path.trim(); - if trimmed.is_empty() { - anyhow::bail!("usage: /refactor "); - } - let source = self - .controller_lock_async() - .await - .read_file(trimmed) - .await?; - let prompt = format!( - "Refactor the file `{}`. Provide specific improvements for readability, safety, and maintainability. Include updated code where relevant.\n\n```text\n{}\n```", - trimmed, source - ); - self.status = format!("Refactor review for {trimmed}..."); - self.dispatch_user_prompt(prompt); - } - SlashCommand::TestPlan => { - let prompt = "Generate a comprehensive test plan for this repository. Outline critical test suites, coverage gaps, and prioritized steps to reach confident automation.".to_string(); - self.status = "Generating test plan...".to_string(); - self.dispatch_user_prompt(prompt); - } - SlashCommand::Compact => { - let prompt = "Compress our conversation history to its essentials. Summarize previous exchanges, preserve critical context, and indicate what state can be safely forgotten.".to_string(); - self.status = "Compacting conversation...".to_string(); - self.dispatch_user_prompt(prompt); - } - SlashCommand::McpTool { server, tool } => { - self.status = format!("Running MCP tool {server}::{tool}..."); - let response = self - .controller_lock_async() - .await - .call_mcp_tool(&server, &tool, json!({})) - .await - .map_err(|err| { - anyhow!("Failed to invoke MCP tool {}::{}: {}", server, tool, err) - })?; - - let content = Self::format_mcp_slash_message(&server, &tool, &response); - self.push_system_message(content); - self.auto_scroll.stick_to_bottom = true; - self.new_message_alert = true; - - if response.success { - self.status = format!("MCP {server}::{tool} result added to chat."); - self.push_toast(ToastLevel::Info, format!("MCP {server}::{tool} completed.")); - } else { - self.status = format!("MCP {server}::{tool} reported an error (see chat)."); - self.push_toast( - ToastLevel::Warning, - format!("MCP {server}::{tool} reported an error."), - ); - } - self.error = None; - } - } - Ok(()) - } - - fn schedule_oauth_poll( - &self, - server: String, - authorization: DeviceAuthorization, - delay: Duration, - ) { - let sender = self.session_tx.clone(); - tokio::spawn(async move { - tokio::time::sleep(delay).await; - let _ = sender.send(SessionEvent::OAuthPoll { - server, - authorization, - }); - }); - } - - async fn start_oauth_login(&mut self, server: &str) -> Result<()> { - if self.oauth_flows.contains_key(server) { - self.error = Some(format!("OAuth flow for '{server}' is already in progress.")); - return Ok(()); - } - - let authorization_result = { - let controller = self.controller_lock_async().await; - controller.start_oauth_device_flow(server).await - }; - let authorization = match authorization_result { - Ok(auth) => auth, - Err(err) => { - self.error = Some(format!("Failed to start OAuth for '{server}': {err}")); - return Ok(()); - } - }; - - self.oauth_flows - .insert(server.to_string(), authorization.clone()); - - let link = authorization - .verification_uri_complete - .clone() - .unwrap_or_else(|| authorization.verification_uri.clone()); - let status = format!( - "Authorize '{server}' via {} (code {}).", - link, authorization.user_code - ); - self.status = status; - self.error = None; - - let mut message = format!( - "OAuth authorization required for `{server}`.\nVisit:\n{}\nEnter code: `{}`", - link, authorization.user_code - ); - if let Some(hint) = &authorization.message - && !hint.trim().is_empty() - { - message.push_str("\n\n"); - message.push_str(hint); - } - if authorization.expires_at > Utc::now() { - message.push_str(&format!( - "\n\nThis code expires at {}.", - authorization - .expires_at - .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) - )); - } - - self.push_system_message(message); - self.auto_scroll.stick_to_bottom = true; - self.notify_new_activity(); - - self.push_toast( - ToastLevel::Warning, - format!("Authorize {server}: code {}", authorization.user_code), - ); - - let delay = authorization.interval; - self.schedule_oauth_poll(server.to_string(), authorization.clone(), delay); - Ok(()) - } - - fn dispatch_user_prompt(&mut self, prompt: String) { - if prompt.trim().is_empty() { - self.error = Some("Slash command generated an empty request".to_string()); - return; - } - - self.push_user_message(prompt); - self.auto_scroll.stick_to_bottom = true; - self.pending_llm_request = true; - self.set_system_status(String::new()); - self.error = None; - } - - fn set_code_view_content( - &mut self, - display_path: impl Into, - absolute: Option, - content: String, - ) { - let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); - if content.ends_with('\n') { - lines.push(String::new()); - } - let display = display_path.into(); - self.code_workspace - .set_active_contents(absolute, Some(display), lines); - self.ensure_focus_valid(); - } - - fn repo_layout_slug(&self) -> String { - self.file_tree() - .repo_name() - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - '-' - } - }) - .collect() - } - - fn workspace_layout_path(&self) -> Result { - let base = data_local_dir().or_else(config_dir).ok_or_else(|| { - anyhow!("Unable to determine configuration directory for layout persistence") - })?; - - let mut dir = base.join("owlen").join("layouts"); - fs::create_dir_all(&dir) - .with_context(|| format!("Failed to create layout directory at {}", dir.display()))?; - - let mut hasher = DefaultHasher::new(); - self.file_tree().root().to_string_lossy().hash(&mut hasher); - let slug = self.repo_layout_slug(); - dir.push(format!("{}-{}.toml", slug, hasher.finish())); - Ok(dir) - } - - fn persist_workspace_layout(&mut self) { - if self.code_workspace.tabs().is_empty() { - return; - } - - let snapshot = self.code_workspace.snapshot(); - match (self.workspace_layout_path(), toml::to_string(&snapshot)) { - (Ok(path), Ok(serialized)) => { - if let Err(err) = fs::write(&path, serialized) { - eprintln!( - "Warning: failed to write workspace layout {}: {}", - path.display(), - err - ); - } - } - (Err(err), _) => { - eprintln!("Warning: unable to determine layout path: {err}"); - } - (_, Err(err)) => { - eprintln!("Warning: failed to serialize workspace layout: {err}"); - } - } - } - - fn restore_pane_from_request(&mut self, request: PaneRestoreRequest) -> Result<()> { - let Some(absolute) = request.absolute_path.as_ref() else { - return Ok(()); - }; - - let content = fs::read_to_string(absolute) - .with_context(|| format!("Failed to read restored file {}", absolute.display()))?; - let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); - if content.ends_with('\n') { - lines.push(String::new()); - } - - let display = request.display_path.clone().or_else(|| { - diff_paths(absolute, self.file_tree().root()).map(|path| { - if path.as_os_str().is_empty() { - ".".to_string() - } else { - path.to_string_lossy().into_owned() - } - }) - }); - - if self.code_workspace.set_pane_contents( - request.pane_id, - Some(absolute.clone()), - display, - lines, - ) { - self.code_workspace - .restore_scroll(request.pane_id, &request.scroll); - } - - Ok(()) - } - - async fn restore_workspace_layout(&mut self) -> Result { - let path = match self.workspace_layout_path() { - Ok(path) => path, - Err(_) => return Ok(false), - }; - - if !path.exists() { - return Ok(false); - } - - let contents = fs::read_to_string(&path) - .with_context(|| format!("Failed to read workspace layout {}", path.display()))?; - let snapshot: WorkspaceSnapshot = toml::from_str(&contents) - .with_context(|| format!("Failed to parse workspace layout {}", path.display()))?; - - let requests = self.code_workspace.apply_snapshot(snapshot); - let mut restored_any = false; - for request in requests { - if let Err(err) = self.restore_pane_from_request(request) { - eprintln!("Warning: failed to restore pane from layout: {err}"); - } else { - restored_any = true; - } - } - - if restored_any { - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - self.status = "Workspace layout restored".to_string(); - } - - Ok(restored_any) - } - - fn direction_label(direction: PaneDirection) -> &'static str { - match direction { - PaneDirection::Left => "←", - PaneDirection::Right => "→", - PaneDirection::Up => "↑", - PaneDirection::Down => "↓", - } - } - - fn handle_workspace_focus_move(&mut self, direction: PaneDirection) { - if self.code_workspace.move_focus(direction) { - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - if let Some(share) = self.code_workspace.active_share() { - self.status = format!( - "Focused pane {} · {:.0}% share", - Self::direction_label(direction), - (share * 100.0).round() - ); - } else { - self.status = format!("Focused pane {}", Self::direction_label(direction)); - } - self.error = None; - self.persist_workspace_layout(); - } else { - self.status = "No pane in that direction".to_string(); - } - } - - fn handle_workspace_resize(&mut self, direction: PaneDirection) { - let now = Instant::now(); - let is_double = self - .last_resize_tap - .map(|(prev_dir, instant)| { - prev_dir == direction && now.duration_since(instant) <= RESIZE_DOUBLE_TAP_WINDOW - }) - .unwrap_or(false); - - let share_opt = if is_double { - if self.last_snap_direction != Some(direction) { - self.resize_snap_index = 0; - } - let snap = RESIZE_SNAP_VALUES[self.resize_snap_index % RESIZE_SNAP_VALUES.len()]; - let result = self.code_workspace.snap_active_share(direction, snap); - if result.is_some() { - self.last_snap_direction = Some(direction); - self.resize_snap_index = (self.resize_snap_index + 1) % RESIZE_SNAP_VALUES.len(); - } - result - } else { - self.last_snap_direction = None; - self.resize_snap_index = 0; - self.code_workspace - .resize_active_step(direction, RESIZE_STEP) - }; - - match share_opt { - Some(share) => { - if is_double { - self.status = format!( - "Pane snapped {} · {:.0}% share", - Self::direction_label(direction), - (share * 100.0).round() - ); - } else { - self.status = format!( - "Pane resized {} · {:.0}% share", - Self::direction_label(direction), - (share * 100.0).round() - ); - } - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - self.error = None; - self.persist_workspace_layout(); - if is_double { - self.last_resize_tap = None; - } else { - self.last_resize_tap = Some((direction, now)); - } - } - None => { - self.status = "No adjacent split to resize".to_string(); - self.last_resize_tap = Some((direction, now)); - self.last_snap_direction = None; - } - } - } - - fn prepare_code_view_target(&mut self, disposition: FileOpenDisposition) -> bool { - match disposition { - FileOpenDisposition::Primary => true, - FileOpenDisposition::SplitHorizontal => self - .code_workspace - .split_active(SplitAxis::Horizontal) - .is_some(), - FileOpenDisposition::SplitVertical => self - .code_workspace - .split_active(SplitAxis::Vertical) - .is_some(), - FileOpenDisposition::Tab => { - self.code_workspace.open_new_tab(); - true - } - } - } - - fn display_label_for_absolute(&self, absolute: &Path) -> String { - let root = self.file_tree().root(); - if let Some(relative) = diff_paths(absolute, root) { - let rel_str = relative.to_string_lossy().into_owned(); - if rel_str.is_empty() { - ".".to_string() - } else { - rel_str - } - } else { - absolute.to_string_lossy().into_owned() - } - } - - fn buffer_label(&self, display: Option<&str>, absolute: Option<&Path>) -> String { - if let Some(display) = display { - let trimmed = display.trim(); - if trimmed.is_empty() { - "untitled buffer".to_string() - } else { - trimmed.to_string() - } - } else if let Some(absolute) = absolute { - self.display_label_for_absolute(absolute) - } else { - "untitled buffer".to_string() - } - } - - async fn save_active_code_buffer( - &mut self, - path_arg: Option, - force: bool, - ) -> Result { - let pane_snapshot = if let Some(pane) = self.code_workspace.active_pane() { - ( - pane.lines.join("\n"), - pane.absolute_path().map(Path::to_path_buf), - pane.display_path().map(|s| s.to_string()), - pane.is_dirty, - ) - } else { - self.status = "No active file to save".to_string(); - self.error = Some("Open a file before saving".to_string()); - return Ok(SaveStatus::Failed); - }; - - let (content, existing_absolute, existing_display, was_dirty) = pane_snapshot; - - if !was_dirty && path_arg.is_none() && !force { - let label = - self.buffer_label(existing_display.as_deref(), existing_absolute.as_deref()); - self.status = format!("No changes to write ({label})"); - self.error = None; - return Ok(SaveStatus::NoChanges); - } - - let (request_path, target_absolute, target_display) = if let Some(path_arg) = path_arg { - let trimmed = path_arg.trim(); - if trimmed.is_empty() { - self.status = "Save aborted: empty path".to_string(); - self.error = Some("Provide a path to save this buffer".to_string()); - return Ok(SaveStatus::Failed); - } - - let provided_path = PathBuf::from(trimmed); - let absolute = self.absolute_tree_path(&provided_path); - let request = if provided_path.is_absolute() { - provided_path.to_string_lossy().into_owned() - } else { - trimmed.to_string() - }; - let display = self.display_label_for_absolute(&absolute); - (request, absolute, display) - } else if let Some(display) = existing_display.clone() { - let path = PathBuf::from(&display); - let absolute = if path.is_absolute() { - path.clone() - } else { - self.absolute_tree_path(&path) - }; - let display_label = self.display_label_for_absolute(&absolute); - (display, absolute, display_label) - } else if let Some(absolute) = existing_absolute.clone() { - let request = absolute.to_string_lossy().into_owned(); - let display = self.display_label_for_absolute(&absolute); - (request, absolute, display) - } else { - self.status = "No path associated with buffer".to_string(); - self.error = Some("Use :w to save this buffer".to_string()); - return Ok(SaveStatus::Failed); - }; - - let write_result = { - let controller = self.controller_lock_async().await; - controller.write_file(&request_path, &content).await - }; - match write_result { - Ok(()) => { - if let Some(tab) = self.code_workspace.active_tab_mut() { - if let Some(pane) = tab.active_pane_mut() { - pane.update_paths( - Some(target_absolute.clone()), - Some(target_display.clone()), - ); - pane.is_dirty = false; - pane.is_staged = false; - } - tab.update_title_from_active(); - } - - match self.file_tree_mut().refresh() { - Ok(()) => { - self.file_tree_mut().reveal(&target_absolute); - self.ensure_focus_valid(); - } - Err(err) => { - self.error = Some(format!( - "Saved {} but failed to refresh tree: {}", - target_display, err - )); - } - } - - self.status = format!("Wrote {}", target_display); - if self.error.is_none() { - self.set_system_status(format!("Saved {}", target_display)); - } - Ok(SaveStatus::Saved) - } - Err(err) => { - self.error = Some(format!("Failed to save {}: {}", target_display, err)); - self.status = format!("Failed to save {}", target_display); - Ok(SaveStatus::Failed) - } - } - } - - fn close_active_code_buffer(&mut self, force: bool) -> bool { - let snapshot = if let Some(pane) = self.code_workspace.active_pane() { - ( - pane.display_path().map(|s| s.to_string()), - pane.absolute_path().map(Path::to_path_buf), - pane.is_dirty, - ) - } else { - self.status = "No active file to close".to_string(); - self.error = Some("Open a file before closing it".to_string()); - return false; - }; - - let (display_path, absolute_path, is_dirty) = snapshot; - - if is_dirty && !force { - let label = self.buffer_label(display_path.as_deref(), absolute_path.as_deref()); - self.status = format!("Unsaved changes in {label} — use :w to save or :q! to discard"); - self.error = Some(format!("Unsaved changes detected in {}", label)); - return false; - } - - let label = self.buffer_label(display_path.as_deref(), absolute_path.as_deref()); - self.close_code_view(); - self.status = format!("Closed {}", label); - self.error = None; - self.set_system_status(String::new()); - true - } - - fn split_active_pane(&mut self, axis: SplitAxis) { - let Some(snapshot) = self.code_workspace.active_pane().cloned() else { - self.status = "No pane to split".to_string(); - return; - }; - - if self.code_workspace.split_active(axis).is_some() { - let lines = snapshot.lines.clone(); - let absolute = snapshot.absolute_path.clone(); - let display = snapshot.display_path.clone(); - self.code_workspace - .set_active_contents(absolute, display, lines); - if let Some(pane) = self.code_workspace.active_pane_mut() { - pane.is_dirty = snapshot.is_dirty; - pane.is_staged = snapshot.is_staged; - pane.viewport_height = snapshot.viewport_height; - pane.scroll = snapshot.scroll.clone(); - } - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - self.status = match axis { - SplitAxis::Horizontal => "Split pane horizontally".to_string(), - SplitAxis::Vertical => "Split pane vertically".to_string(), - }; - self.error = None; - self.persist_workspace_layout(); - } else { - self.status = "Unable to split pane".to_string(); - self.error = Some("Unable to split pane".to_string()); - } - } - - fn close_code_view(&mut self) { - self.code_workspace.clear_active_pane(); - if matches!(self.focused_panel, FocusedPanel::Code) { - self.focused_panel = FocusedPanel::Chat; - } - self.ensure_focus_valid(); - self.persist_workspace_layout(); - } - - fn absolute_tree_path(&self, path: &Path) -> PathBuf { - if path.as_os_str().is_empty() { - self.file_tree().root().to_path_buf() - } else if path.is_absolute() { - path.to_path_buf() - } else { - self.file_tree().root().join(path) - } - } - - fn relative_tree_display(&self, path: &Path) -> String { - if path.as_os_str().is_empty() { - ".".to_string() - } else { - path.to_string_lossy().into_owned() - } - } - - async fn open_selected_file_from_tree( - &mut self, - disposition: FileOpenDisposition, - ) -> Result<()> { - if !self.is_code_mode() { - self.status = "Switch to code mode to open files".to_string(); - self.error = None; - return Ok(()); - } - - let selected_opt = { - let tree = self.file_tree(); - tree.selected_node().cloned() - }; - - let Some(selected) = selected_opt else { - self.status = "No file selected".to_string(); - return Ok(()); - }; - - if selected.is_dir { - let was_expanded = selected.is_expanded; - self.file_tree_mut().toggle_expand(); - let label = self.relative_tree_display(&selected.path); - self.status = if was_expanded { - format!("Collapsed {}", label) - } else { - format!("Expanded {}", label) - }; - return Ok(()); - } - - if selected.path.as_os_str().is_empty() { - return Ok(()); - } - - let relative_display = self.relative_tree_display(&selected.path); - let absolute_path = self.absolute_tree_path(&selected.path); - let request_path = if selected.path.is_absolute() { - selected.path.to_string_lossy().into_owned() - } else { - relative_display.clone() - }; - - let file_content = { - let controller = self.controller_lock_async().await; - controller.read_file_with_tools(&request_path).await - }; - - match file_content { - Ok(content) => { - let prepared = self.prepare_code_view_target(disposition); - self.set_code_view_content( - relative_display.clone(), - Some(absolute_path.clone()), - content, - ); - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - self.file_tree_mut().reveal(&absolute_path); - if !prepared { - self.error = - Some("Unable to create requested split; opened in active pane".to_string()); - } else { - self.error = None; - } - self.status = match (disposition, prepared) { - (FileOpenDisposition::Primary, _) => format!("Opened {}", relative_display), - (FileOpenDisposition::SplitHorizontal, true) => { - format!("Opened {} in horizontal split", relative_display) - } - (FileOpenDisposition::SplitVertical, true) => { - format!("Opened {} in vertical split", relative_display) - } - (FileOpenDisposition::Tab, true) => { - format!("Opened {} in new tab", relative_display) - } - (FileOpenDisposition::SplitHorizontal, false) - | (FileOpenDisposition::SplitVertical, false) => { - format!("Opened {} (split unavailable)", relative_display) - } - (FileOpenDisposition::Tab, false) => { - format!("Opened {} (tab unavailable)", relative_display) - } - }; - self.set_system_status(format!("Viewing {}", relative_display)); - self.persist_workspace_layout(); - } - Err(err) => { - self.error = Some(format!("Failed to open {}: {}", relative_display, err)); - } - } - - Ok(()) - } - - fn copy_selected_path(&mut self, relative: bool) { - let selected_opt = { - let tree = self.file_tree(); - tree.selected_node().cloned() - }; - - let Some(selected) = selected_opt else { - self.status = "No file selected".to_string(); - return; - }; - - let path_string = if relative { - self.relative_tree_display(&selected.path) - } else { - let abs = self.absolute_tree_path(&selected.path); - abs.to_string_lossy().into_owned() - }; - - self.clipboard = path_string.clone(); - self.status = if relative { - format!("Copied relative path: {}", path_string) - } else { - format!("Copied path: {}", path_string) - }; - self.error = None; - } - - fn selected_file_node(&self) -> Option { - let tree = self.file_tree(); - tree.selected_node().cloned() - } - - fn mutate_file_filter(&mut self, mutate: F) - where - F: FnOnce(&mut String), - { - let mut query = { - let tree = self.file_tree(); - tree.filter_query().to_string() - }; - - mutate(&mut query); - - let query_is_empty = query.is_empty(); - - { - let tree = self.file_tree_mut(); - if query_is_empty { - tree.set_filter_mode(FileFilterMode::Glob); - } - tree.set_filter_query(query.clone()); - } - - if query_is_empty { - self.status = "Filter cleared".to_string(); - } else { - let mode = match self.file_tree().filter_mode() { - FileFilterMode::Glob => "glob", - FileFilterMode::Fuzzy => "fuzzy", - }; - self.status = format!("Filter ({mode}): {}", query); - } - self.error = None; - } - - fn backspace_file_filter(&mut self) { - self.mutate_file_filter(|query| { - query.pop(); - }); - } - - fn clear_file_filter(&mut self) { - self.mutate_file_filter(|query| { - query.clear(); - }); - } - - fn append_file_filter_char(&mut self, ch: char) { - self.mutate_file_filter(|query| { - query.push(ch); - }); - } - - fn toggle_hidden_files(&mut self) { - match self.file_tree_mut().toggle_hidden() { - Ok(()) => { - let show_hidden = self.file_tree().show_hidden(); - self.status = if show_hidden { - "Hidden files visible".to_string() - } else { - "Hidden files hidden".to_string() - }; - self.error = None; - } - Err(err) => { - self.error = Some(format!("Failed to toggle hidden files: {}", err)); - } - } - } - - fn create_file_from_command(&mut self, path: &str) -> Result { - if !self.is_code_mode() { - return Err(anyhow!("File creation is only available in code mode")); - } - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err(anyhow!("File path cannot be empty")); - } - - let relative = PathBuf::from(trimmed); - validate_relative_path(&relative, true)?; - - let file_name = relative - .file_name() - .ok_or_else(|| anyhow!("File path must include a file name"))? - .to_string_lossy() - .into_owned(); - - let base = relative - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .map(|parent| parent.to_path_buf()) - .unwrap_or_else(PathBuf::new); - - let prompt = FileActionPrompt::new(FileActionKind::CreateFile { base }, file_name); - let message = self.perform_file_action(prompt)?; - self.expand_file_panel(); - Ok(message) - } - - pub fn file_panel_prompt_text(&self) -> Option<(String, bool)> { - self.pending_file_action.as_ref().map(|prompt| { - ( - self.describe_file_action_prompt(prompt), - prompt.is_destructive(), - ) - }) - } - - fn describe_file_action_prompt(&self, prompt: &FileActionPrompt) -> String { - let buffer_display = if prompt.buffer.trim().is_empty() { - "".to_string() - } else { - prompt.buffer.clone() - }; - - let base_message = match &prompt.kind { - FileActionKind::CreateFile { base } => { - let base_display = self.relative_tree_display(base); - format!("Create file in {} ▸ {}", base_display, buffer_display) - } - FileActionKind::CreateFolder { base } => { - let base_display = self.relative_tree_display(base); - format!("Create folder in {} ▸ {}", base_display, buffer_display) - } - FileActionKind::Rename { original } => { - let current_display = self.relative_tree_display(original); - format!("Rename {} → {}", current_display, buffer_display) - } - FileActionKind::Move { original } => { - let current_display = self.relative_tree_display(original); - format!("Move {} → {}", current_display, buffer_display) - } - FileActionKind::Delete { target, .. } => { - let target_display = self.relative_tree_display(target); - format!( - "Delete {} — type filename to confirm ▸ {}", - target_display, buffer_display - ) - } - }; - - format!("{base_message} (Enter to apply · Esc to cancel)") - } - - fn begin_file_action(&mut self, kind: FileActionKind, initial: impl Into) { - if !self.is_code_mode() { - self.status = "Switch to code mode to manage files".to_string(); - self.error = None; - return; - } - let prompt = FileActionPrompt::new(kind, initial); - self.status = self.describe_file_action_prompt(&prompt); - self.error = None; - self.pending_file_action = Some(prompt); - } - - fn refresh_file_action_status(&mut self) { - if let Some(prompt) = self.pending_file_action.as_ref() { - self.status = self.describe_file_action_prompt(prompt); - } - } - - fn cancel_file_action(&mut self) { - self.pending_file_action = None; - self.status = "File action cancelled".to_string(); - self.error = None; - } - - fn handle_file_action_prompt(&mut self, key: &crossterm::event::KeyEvent) -> Result { - use crossterm::event::{KeyCode, KeyModifiers}; - - if self.pending_file_action.is_none() { - return Ok(false); - } - - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - let alt = key.modifiers.contains(KeyModifiers::ALT); - - match key.code { - KeyCode::Enter if !ctrl && !alt => { - self.apply_pending_file_action()?; - return Ok(true); - } - KeyCode::Esc if !ctrl && !alt => { - self.cancel_file_action(); - return Ok(true); - } - KeyCode::Backspace if !ctrl && !alt => { - if let Some(prompt) = self.pending_file_action.as_mut() { - prompt.pop_char(); - } - self.refresh_file_action_status(); - self.error = None; - return Ok(true); - } - KeyCode::Char(c) if !ctrl && !alt => { - if let Some(prompt) = self.pending_file_action.as_mut() { - prompt.push_char(c); - } - self.refresh_file_action_status(); - self.error = None; - return Ok(true); - } - KeyCode::Tab if !ctrl && !alt => { - if let Some(prompt) = self.pending_file_action.as_mut() { - prompt.push_char('\t'); - } - self.refresh_file_action_status(); - self.error = None; - return Ok(true); - } - KeyCode::Delete if !ctrl && !alt => { - if let Some(prompt) = self.pending_file_action.as_mut() { - prompt.set_buffer(String::new()); - } - self.refresh_file_action_status(); - self.error = None; - return Ok(true); - } - _ => {} - } - - Ok(false) - } - - fn apply_pending_file_action(&mut self) -> Result<()> { - let Some(prompt) = self.pending_file_action.take() else { - return Ok(()); - }; - let cloned_prompt = prompt.clone(); - match self.perform_file_action(prompt) { - Ok(message) => { - self.status = message; - self.error = None; - Ok(()) - } - Err(err) => { - self.pending_file_action = Some(cloned_prompt); - self.error = Some(err.to_string()); - Err(err) - } - } - } - - async fn launch_external_editor(&mut self) -> Result<()> { - if !self.is_code_mode() { - self.status = "Switch to code mode to launch the external editor".to_string(); - self.error = None; - return Ok(()); - } - let Some(selected) = self.selected_file_node() else { - self.status = "No file selected".to_string(); - return Ok(()); - }; - let relative = selected.path.clone(); - let absolute = self.absolute_tree_path(&relative); - let editor = env::var("EDITOR") - .or_else(|_| env::var("VISUAL")) - .unwrap_or_else(|_| "vi".to_string()); - - self.status = format!("Launching {} {}", editor, absolute.display()); - self.error = None; - - let editor_cmd = editor.clone(); - let path_arg = absolute.clone(); - - let raw_mode_disabled = disable_raw_mode().is_ok(); - let join_result = - task::spawn_blocking(move || Command::new(&editor_cmd).arg(&path_arg).status()).await; - if raw_mode_disabled { - let _ = enable_raw_mode(); - } - let join_result = join_result.context("Editor task failed to join")?; - - match join_result { - Ok(status) => { - if status.success() { - self.status = format!( - "Closed {} for {}", - editor, - self.relative_tree_display(&relative) - ); - self.error = None; - } else { - let code = status - .code() - .map(|c| c.to_string()) - .unwrap_or_else(|| "signal".to_string()); - self.error = Some(format!("{} exited with status {}", editor, code)); - } - } - Err(err) => { - self.error = Some(format!("Failed to launch {}: {}", editor, err)); - } - } - - match self.file_tree_mut().refresh() { - Ok(()) => { - self.file_tree_mut().reveal(&absolute); - self.ensure_focus_valid(); - } - Err(err) => { - self.error = Some(format!("Failed to refresh file tree: {}", err)); - } - } - - Ok(()) - } - - fn perform_file_action(&mut self, prompt: FileActionPrompt) -> Result { - if !self.is_code_mode() { - return Err(anyhow!("File actions are only available in code mode")); - } - match prompt.kind { - FileActionKind::CreateFile { base } => { - let name = prompt.buffer.trim(); - if name.is_empty() { - return Err(anyhow!("File name cannot be empty")); - } - let name_path = PathBuf::from(name); - validate_relative_path(&name_path, true)?; - let relative = if base.as_os_str().is_empty() { - name_path - } else { - base.join(name_path) - }; - let absolute = self.absolute_tree_path(&relative); - if absolute.exists() { - return Err(anyhow!("{} already exists", absolute.display())); - } - if let Some(parent) = absolute.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create parent directories for {}", - absolute.display() - ) - })?; - } - OpenOptions::new() - .create_new(true) - .write(true) - .open(&absolute) - .with_context(|| format!("Failed to create {}", absolute.display()))?; - self.file_tree_mut() - .refresh() - .context("Failed to refresh file tree")?; - self.file_tree_mut().reveal(&absolute); - self.ensure_focus_valid(); - Ok(format!( - "Created file {}", - self.relative_tree_display(&relative) - )) - } - FileActionKind::CreateFolder { base } => { - let name = prompt.buffer.trim(); - if name.is_empty() { - return Err(anyhow!("Folder name cannot be empty")); - } - let name_path = PathBuf::from(name); - validate_relative_path(&name_path, true)?; - let relative = if base.as_os_str().is_empty() { - name_path - } else { - base.join(name_path) - }; - let absolute = self.absolute_tree_path(&relative); - if absolute.exists() { - return Err(anyhow!("{} already exists", absolute.display())); - } - fs::create_dir_all(&absolute) - .with_context(|| format!("Failed to create {}", absolute.display()))?; - self.file_tree_mut() - .refresh() - .context("Failed to refresh file tree")?; - self.file_tree_mut().reveal(&absolute); - self.ensure_focus_valid(); - Ok(format!( - "Created folder {}", - self.relative_tree_display(&relative) - )) - } - FileActionKind::Rename { original } => { - if original.as_os_str().is_empty() { - return Err(anyhow!("Cannot rename workspace root")); - } - let name = prompt.buffer.trim(); - if name.is_empty() { - return Err(anyhow!("New name cannot be empty")); - } - validate_relative_path(Path::new(name), false)?; - let new_relative = original - .parent() - .map(|parent| { - if parent.as_os_str().is_empty() { - PathBuf::from(name) - } else { - parent.join(name) - } - }) - .unwrap_or_else(|| PathBuf::from(name)); - let source_abs = self.absolute_tree_path(&original); - let target_abs = self.absolute_tree_path(&new_relative); - if target_abs.exists() { - return Err(anyhow!("{} already exists", target_abs.display())); - } - fs::rename(&source_abs, &target_abs).with_context(|| { - format!( - "Failed to rename {} to {}", - source_abs.display(), - target_abs.display() - ) - })?; - self.file_tree_mut() - .refresh() - .context("Failed to refresh file tree")?; - self.file_tree_mut().reveal(&target_abs); - self.ensure_focus_valid(); - Ok(format!( - "Renamed {} to {}", - self.relative_tree_display(&original), - self.relative_tree_display(&new_relative) - )) - } - FileActionKind::Move { original } => { - if original.as_os_str().is_empty() { - return Err(anyhow!("Cannot move workspace root")); - } - let target = prompt.buffer.trim(); - if target.is_empty() { - return Err(anyhow!("Target path cannot be empty")); - } - let target_relative = PathBuf::from(target); - validate_relative_path(&target_relative, true)?; - let source_abs = self.absolute_tree_path(&original); - let target_abs = self.absolute_tree_path(&target_relative); - if target_abs.exists() { - return Err(anyhow!("{} already exists", target_abs.display())); - } - if let Some(parent) = target_abs.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create parent directories for {}", - target_abs.display() - ) - })?; - } - fs::rename(&source_abs, &target_abs).with_context(|| { - format!( - "Failed to move {} to {}", - source_abs.display(), - target_abs.display() - ) - })?; - self.file_tree_mut() - .refresh() - .context("Failed to refresh file tree")?; - self.file_tree_mut().reveal(&target_abs); - self.ensure_focus_valid(); - Ok(format!( - "Moved {} to {}", - self.relative_tree_display(&original), - self.relative_tree_display(&target_relative) - )) - } - FileActionKind::Delete { target, confirm } => { - if target.as_os_str().is_empty() { - return Err(anyhow!("Cannot delete workspace root")); - } - let typed = prompt.buffer.trim(); - if typed != confirm { - return Err(anyhow!("Type '{}' to confirm deletion", confirm)); - } - let absolute = self.absolute_tree_path(&target); - if absolute.is_dir() { - fs::remove_dir_all(&absolute).with_context(|| { - format!("Failed to delete directory {}", absolute.display()) - })?; - } else if absolute.exists() { - fs::remove_file(&absolute) - .with_context(|| format!("Failed to delete file {}", absolute.display()))?; - } else { - return Err(anyhow!("{} does not exist", absolute.display())); - } - self.file_tree_mut() - .refresh() - .context("Failed to refresh file tree")?; - if let Some(parent) = target.parent() { - let parent_abs = if parent.as_os_str().is_empty() { - self.file_tree().root().to_path_buf() - } else { - self.absolute_tree_path(parent) - }; - self.file_tree_mut().reveal(&parent_abs); - } - self.ensure_focus_valid(); - Ok(format!("Deleted {}", self.relative_tree_display(&target))) - } - } - } - - fn reveal_path_in_file_tree(&mut self, path: &Path) { - if !self.is_code_mode() { - self.status = "Switch to code mode to reveal files".to_string(); - self.error = None; - return; - } - let absolute = self.absolute_tree_path(path); - self.expand_file_panel(); - self.file_tree_mut().reveal(&absolute); - self.focused_panel = FocusedPanel::Files; - self.ensure_focus_valid(); - let display = absolute - .strip_prefix(self.file_tree().root()) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|_| absolute.to_string_lossy().into_owned()); - self.status = format!("Revealed {}", display); - } - - fn reveal_active_file(&mut self) { - let path_opt = self.code_workspace.active_pane().and_then(|pane| { - pane.absolute_path().map(Path::to_path_buf).or_else(|| { - pane.display_path() - .map(|display| PathBuf::from(display.to_string())) - }) - }); - - match path_opt { - Some(path) => self.reveal_path_in_file_tree(&path), - None => { - self.status = "No active file to reveal".to_string(); - } - } - } - - async fn handle_file_panel_key(&mut self, key: &crossterm::event::KeyEvent) -> Result { - use crossterm::event::{KeyCode, KeyModifiers}; - - if !self.is_code_mode() { - return Ok(false); - } - - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - let shift = key.modifiers.contains(KeyModifiers::SHIFT); - let alt = key.modifiers.contains(KeyModifiers::ALT); - let no_modifiers = key.modifiers.is_empty(); - - if self.pending_file_action.is_some() && self.handle_file_action_prompt(key)? { - return Ok(true); - } - - match key.code { - KeyCode::Enter => { - self.open_selected_file_from_tree(FileOpenDisposition::Primary) - .await?; - return Ok(true); - } - KeyCode::Char('o') if no_modifiers => { - self.open_selected_file_from_tree(FileOpenDisposition::SplitHorizontal) - .await?; - return Ok(true); - } - KeyCode::Char('O') if shift && !ctrl && !alt => { - self.open_selected_file_from_tree(FileOpenDisposition::SplitVertical) - .await?; - return Ok(true); - } - KeyCode::Char('t') if no_modifiers => { - self.open_selected_file_from_tree(FileOpenDisposition::Tab) - .await?; - return Ok(true); - } - KeyCode::Char('y') if no_modifiers => { - self.copy_selected_path(false); - return Ok(true); - } - KeyCode::Char('Y') if shift && !ctrl && !alt => { - self.copy_selected_path(true); - return Ok(true); - } - KeyCode::Char('A') if shift && !ctrl && !alt => { - if let Some(selected) = self.selected_file_node() { - let base = if selected.is_dir { - selected.path.clone() - } else { - selected - .path - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(PathBuf::new) - }; - self.begin_file_action(FileActionKind::CreateFolder { base }, String::new()); - } else { - self.status = "No file selected".to_string(); - } - return Ok(true); - } - KeyCode::Char('r') if no_modifiers => { - if let Some(selected) = self.selected_file_node() { - if selected.path.as_os_str().is_empty() { - self.error = Some("Cannot rename workspace root".to_string()); - } else { - let initial = selected - .path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - self.begin_file_action( - FileActionKind::Rename { - original: selected.path.clone(), - }, - initial, - ); - } - } else { - self.status = "No file selected".to_string(); - } - return Ok(true); - } - KeyCode::Char('m') if no_modifiers => { - if let Some(selected) = self.selected_file_node() { - if selected.path.as_os_str().is_empty() { - self.error = Some("Cannot move workspace root".to_string()); - } else { - let initial = self.relative_tree_display(&selected.path); - self.begin_file_action( - FileActionKind::Move { - original: selected.path.clone(), - }, - initial, - ); - } - } else { - self.status = "No file selected".to_string(); - } - return Ok(true); - } - KeyCode::Char('d') if no_modifiers => { - if let Some(selected) = self.selected_file_node() { - if selected.path.as_os_str().is_empty() { - self.error = Some("Cannot delete workspace root".to_string()); - } else { - let confirm = selected - .path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - if confirm.is_empty() { - self.error = - Some("Unable to determine file name for confirmation".to_string()); - } else { - self.begin_file_action( - FileActionKind::Delete { - target: selected.path.clone(), - confirm, - }, - String::new(), - ); - } - } - } else { - self.status = "No file selected".to_string(); - } - return Ok(true); - } - KeyCode::Char('.') if no_modifiers => { - self.launch_external_editor().await?; - return Ok(true); - } - KeyCode::Char('/') if !ctrl && !alt => { - if self.file_tree().filter_query().is_empty() { - { - let tree = self.file_tree_mut(); - tree.set_filter_mode(FileFilterMode::Fuzzy); - tree.set_filter_query(String::new()); - } - let mode = match self.file_tree().filter_mode() { - FileFilterMode::Glob => "glob", - FileFilterMode::Fuzzy => "fuzzy", - }; - self.status = format!("Filter ({mode}): type to search"); - self.error = None; - } else { - self.append_file_filter_char('/'); - } - return Ok(true); - } - KeyCode::Backspace if !ctrl && !alt => { - self.backspace_file_filter(); - return Ok(true); - } - KeyCode::Esc if !ctrl && !alt => { - if !self.file_tree().filter_query().is_empty() { - self.clear_file_filter(); - return Ok(true); - } - } - KeyCode::Char(' ') if !ctrl && !alt => { - self.file_tree_mut().toggle_expand(); - return Ok(true); - } - KeyCode::Char(c) if !ctrl && !alt => { - let reserved = matches!( - (c, shift), - ('o', false) - | ('O', true) - | ('t', false) - | ('y', false) - | ('Y', true) - | ('g', _) - | ('d', _) - | ('m', _) - | ('A', true) - | ('r', _) - | ('/', _) - | ('.', _) - ); - if !reserved && !c.is_control() { - self.append_file_filter_char(c); - return Ok(true); - } - } - _ => {} - } - - Ok(false) - } - - fn handle_resize(&mut self, width: u16, _height: u16) { - let approx_content_width = usize::from(width.saturating_sub(6)); - self.content_width = approx_content_width.max(1); - self.auto_scroll.stick_to_bottom = true; - self.thinking_scroll.stick_to_bottom = true; - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.stick_to_bottom = false; - } - } - - pub async fn initialize_models(&mut self) -> Result<()> { - let (config_model_name, config_model_provider) = { - let controller = self.controller_lock_async().await; - let config = controller.config(); - ( - config.general.default_model.clone(), - config.general.default_provider.clone(), - ) - }; - - let (all_models, errors, scope_status) = self.collect_models_from_all_providers().await; - self.models = all_models; - self.provider_scope_status = scope_status; - self.model_details_cache.clear(); - self.model_info_panel.clear(); - self.show_model_info = false; - - self.recompute_available_providers(); - - if self.available_providers.is_empty() { - self.available_providers.push("ollama_local".to_string()); - } - - if !config_model_provider.is_empty() { - self.selected_provider = Self::canonical_provider_id(&config_model_provider); - } else { - self.selected_provider = self.available_providers[0].clone(); - } - - self.expanded_provider = Some(self.selected_provider.clone()); - self.update_selected_provider_index(); - self.sync_selected_model_index().await; - - // Ensure the default model is set in the controller and config (async) - { - let mut controller = self.controller_lock_async().await; - controller.ensure_default_model(&self.models).await; - } - - let (current_model_name, current_model_provider) = { - let controller = self.controller_lock_async().await; - let config = controller.config(); - ( - controller.selected_model().to_string(), - config.general.default_provider.clone(), - ) - }; - - if config_model_name.as_deref() != Some(¤t_model_name) - || config_model_provider != current_model_provider - { - let save_result = { - let controller = self.controller_lock_async().await; - let config = controller.config(); - config::save_config(&config) - }; - if let Err(err) = save_result { - self.error = Some(format!("Failed to save config: {err}")); - } else { - self.error = None; - } - } - - if !errors.is_empty() { - self.error = Some(errors.join("; ")); - } - - self.update_command_palette_catalog(); - - Ok(()) - } - - pub async fn handle_event(&mut self, event: Event) -> Result { - use crossterm::event::{KeyCode, KeyModifiers}; - - if let Some(last) = self.last_ctrl_c - && last.elapsed() > DOUBLE_CTRL_C_WINDOW - { - self.last_ctrl_c = None; - } - - match event { - Event::Tick => { - self.poll_repo_search(); - self.poll_symbol_search(); - self.poll_debug_log_updates(); - self.prune_toasts(); - // Future: update streaming timers - } - Event::Resize(width, height) => { - self.handle_resize(width, height); - } - Event::Paste(text) => { - // Handle paste events - insert text directly without triggering sends - if matches!(self.mode, InputMode::Editing | InputMode::Visual) - && self.textarea.insert_str(&text) - { - self.sync_textarea_to_buffer(); - } - // Ignore paste events in other modes - } - Event::Mouse(mouse) => { - return self.handle_mouse_event(mouse); - } - Event::Key(key) => { - let is_ctrl_c = matches!( - (key.code, key.modifiers), - (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) - ); - - if !is_ctrl_c { - self.last_ctrl_c = None; - } - - // Handle consent dialog first (highest priority) - if let Some(consent_state) = &self.pending_consent { - let scope = match key.code { - KeyCode::Char('1') => Some(ConsentScope::Once), - KeyCode::Char('2') => Some(ConsentScope::Session), - KeyCode::Char('3') => Some(ConsentScope::Permanent), - KeyCode::Char('4') | KeyCode::Esc => Some(ConsentScope::Denied), - _ => None, - }; - - if let Some(scope) = scope { - let request_id = consent_state.request_id; - let effects = - self.apply_app_event(AppEvent::ToolPermission { request_id, scope }); - self.handle_app_effects(effects).await?; - } - return Ok(AppState::Running); - } - - if self.try_execute_command(&key).await? { - return Ok(AppState::Running); - } - - if matches!(key.code, KeyCode::F(1)) { - if matches!(self.mode, InputMode::Help) { - if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) { - self.finish_onboarding(false); - } else { - if HELP_TAB_COUNT > 0 { - self.help_tab_index = 0; - } - self.reset_status(); - self.set_input_mode(InputMode::Normal); - } - } else { - self.open_guidance_overlay(GuidanceOverlay::CheatSheet); - } - return Ok(AppState::Running); - } - - let is_question_mark = matches!( - (key.code, key.modifiers), - (KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT) - ); - let is_reveal_active = key.modifiers.contains(KeyModifiers::CONTROL) - && key.modifiers.contains(KeyModifiers::SHIFT) - && matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R')); - let is_repo_search = key.modifiers.contains(KeyModifiers::CONTROL) - && key.modifiers.contains(KeyModifiers::SHIFT) - && matches!(key.code, KeyCode::Char('f') | KeyCode::Char('F')); - let is_symbol_search_key = key.modifiers.contains(KeyModifiers::CONTROL) - && key.modifiers.contains(KeyModifiers::SHIFT) - && matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')); - let is_resize_left = key.modifiers.contains(KeyModifiers::CONTROL) - && matches!( - key.code, - KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('H') - ); - let is_resize_right = key.modifiers.contains(KeyModifiers::CONTROL) - && matches!( - key.code, - KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') - ); - let is_resize_up = key.modifiers.contains(KeyModifiers::CONTROL) - && matches!( - key.code, - KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') - ); - let is_resize_down = key.modifiers.contains(KeyModifiers::CONTROL) - && matches!( - key.code, - KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') - ); - - if is_reveal_active && matches!(self.mode, InputMode::Normal) { - self.reveal_active_file(); - return Ok(AppState::Running); - } - - if is_question_mark { - match self.mode { - InputMode::Normal => { - self.open_guidance_overlay(GuidanceOverlay::CheatSheet); - } - InputMode::Help => { - if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) { - self.finish_onboarding(false); - } else { - if HELP_TAB_COUNT > 0 { - self.help_tab_index = 0; - } - self.reset_status(); - self.set_input_mode(InputMode::Normal); - } - } - _ => {} - } - return Ok(AppState::Running); - } - - if is_repo_search && matches!(self.mode, InputMode::Normal) { - self.set_input_mode(InputMode::RepoSearch); - if self.repo_search.query_input().is_empty() { - *self.repo_search.status_mut() = - Some("Type a pattern · Enter runs ripgrep".to_string()); - } - self.status = "Repo search active".to_string(); - return Ok(AppState::Running); - } - - if (is_resize_left || is_resize_right) - && matches!(self.mode, InputMode::Normal) - && !self.is_file_panel_collapsed() - { - let current = self.file_panel_width(); - let delta: i16 = if is_resize_left { -2 } else { 2 }; - let candidate = current.saturating_add_signed(delta); - let adjusted = self.set_file_panel_width(candidate); - if adjusted != current { - self.status = format!("Files panel width: {} cols", adjusted); - self.error = None; - } else { - self.status = "Files panel width unchanged".to_string(); - } - return Ok(AppState::Running); - } - - if (is_resize_up || is_resize_down) && matches!(self.mode, InputMode::Normal) { - let delta = if is_resize_up { -0.05 } else { 0.05 }; - self.adjust_vertical_split(delta); - self.status = format!( - "Vertical split adjusted ({})", - if delta.is_sign_positive() { - "down" - } else { - "up" - } - ); - self.error = None; - return Ok(AppState::Running); - } - - if is_symbol_search_key && matches!(self.mode, InputMode::Normal) { - self.set_input_mode(InputMode::SymbolSearch); - self.symbol_search.clear_query(); - self.status = "Symbol search active".to_string(); - self.start_symbol_search().await?; - return Ok(AppState::Running); - } - - match self.mode { - InputMode::Normal => { - // Handle multi-key sequences first - if self.show_model_info - && matches!( - (key.code, key.modifiers), - (KeyCode::Esc, KeyModifiers::NONE) - ) - { - self.set_model_info_visible(false); - self.status = "Closed model info panel".to_string(); - return Ok(AppState::Running); - } - - if matches!(self.focused_panel, FocusedPanel::Files) - && self.handle_file_panel_key(&key).await? - { - return Ok(AppState::Running); - } - - match (key.code, key.modifiers) { - (KeyCode::Left, modifiers) if modifiers.contains(KeyModifiers::ALT) => { - self.handle_workspace_resize(PaneDirection::Left); - return Ok(AppState::Running); - } - (KeyCode::Right, modifiers) - if modifiers.contains(KeyModifiers::ALT) => - { - self.handle_workspace_resize(PaneDirection::Right); - return Ok(AppState::Running); - } - (KeyCode::Up, modifiers) if modifiers.contains(KeyModifiers::ALT) => { - self.handle_workspace_resize(PaneDirection::Up); - return Ok(AppState::Running); - } - (KeyCode::Down, modifiers) if modifiers.contains(KeyModifiers::ALT) => { - self.handle_workspace_resize(PaneDirection::Down); - return Ok(AppState::Running); - } - (KeyCode::Char('c'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - if self.cancel_active_generation()? { - self.last_ctrl_c = None; - return Ok(AppState::Running); - } - - let now = Instant::now(); - if let Some(last) = self.last_ctrl_c - && now.duration_since(last) <= DOUBLE_CTRL_C_WINDOW - { - self.status = "Exiting…".to_string(); - self.set_system_status(String::new()); - self.last_ctrl_c = None; - return Ok(AppState::Quit); - } - - self.last_ctrl_c = Some(now); - self.status = "Press Ctrl+C again to quit".to_string(); - self.set_system_status( - "Press Ctrl+C again to quit OWLEN".to_string(), - ); - return Ok(AppState::Running); - } - (KeyCode::Char('j'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - if self.show_model_info && self.model_info_viewport_height > 0 { - self.model_info_panel - .scroll_down(self.model_info_viewport_height); - } - } - // Mode switches - (KeyCode::Char('v'), KeyModifiers::NONE) => { - if matches!(self.focused_panel, FocusedPanel::Code) { - self.status = - "Code view is read-only; yank text with :open and copy manually." - .to_string(); - return Ok(AppState::Running); - } - self.set_input_mode(InputMode::Visual); - - match self.focused_panel { - FocusedPanel::Input => { - // Sync buffer to textarea before entering visual mode - self.sync_buffer_to_textarea(); - // Set a visible selection style - let selection_style = Style::default() - .bg(crate::color_convert::to_ratatui_color( - &self.theme.selection_bg, - )) - .fg(crate::color_convert::to_ratatui_color( - &self.theme.selection_fg, - )); - self.textarea.set_selection_style(selection_style); - // Start visual selection at current cursor position - self.textarea.start_selection(); - self.visual_start = Some(self.textarea.cursor()); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // For scrollable panels, start selection at cursor position - let cursor = - if matches!(self.focused_panel, FocusedPanel::Chat) { - self.chat_cursor - } else { - self.thinking_cursor - }; - self.visual_start = Some(cursor); - self.visual_end = Some(cursor); - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - self.status = - "-- VISUAL -- (move with j/k, yank with y)".to_string(); - } - (KeyCode::Char(':'), KeyModifiers::NONE) => { - self.set_input_mode(InputMode::Command); - self.command_palette.clear(); - self.command_palette.ensure_suggestions(); - self.status = ":".to_string(); - } - (KeyCode::Char('p'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.set_input_mode(InputMode::Command); - self.command_palette.clear(); - self.command_palette.ensure_suggestions(); - self.status = ":".to_string(); - return Ok(AppState::Running); - } - // Enter editing mode - (KeyCode::Enter, KeyModifiers::NONE) - | (KeyCode::Char('i'), KeyModifiers::NONE) => { - self.set_input_mode(InputMode::Editing); - self.sync_buffer_to_textarea(); - } - (KeyCode::Char('a'), KeyModifiers::NONE) => { - // Append - move right and enter insert mode - self.set_input_mode(InputMode::Editing); - self.sync_buffer_to_textarea(); - self.textarea.move_cursor(tui_textarea::CursorMove::Forward); - } - (KeyCode::Char('A'), KeyModifiers::SHIFT) => { - // Append at end of line - self.set_input_mode(InputMode::Editing); - self.sync_buffer_to_textarea(); - self.textarea.move_cursor(tui_textarea::CursorMove::End); - } - (KeyCode::Char('I'), KeyModifiers::SHIFT) => { - // Insert at start of line - self.set_input_mode(InputMode::Editing); - self.sync_buffer_to_textarea(); - self.textarea.move_cursor(tui_textarea::CursorMove::Head); - } - (KeyCode::Char('o'), KeyModifiers::NONE) => { - // Insert newline below and enter edit mode - self.set_input_mode(InputMode::Editing); - self.sync_buffer_to_textarea(); - self.textarea.move_cursor(tui_textarea::CursorMove::End); - self.textarea.insert_newline(); - } - (KeyCode::Char('O'), KeyModifiers::NONE) => { - // Insert newline above and enter edit mode - self.set_input_mode(InputMode::Editing); - self.sync_buffer_to_textarea(); - self.textarea.move_cursor(tui_textarea::CursorMove::Head); - self.textarea.insert_newline(); - self.textarea.move_cursor(tui_textarea::CursorMove::Up); - } - // Basic scrolling and cursor movement - (KeyCode::Up, KeyModifiers::NONE) - | (KeyCode::Char('k'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Chat => { - if self.chat_cursor.0 > 0 { - self.chat_cursor.0 -= 1; - // Scroll if cursor moves above viewport - if self.chat_cursor.0 < self.auto_scroll.scroll { - self.on_scroll(-1); - } - } - } - FocusedPanel::Thinking => { - if self.thinking_cursor.0 > 0 { - self.thinking_cursor.0 -= 1; - if self.thinking_cursor.0 < self.thinking_scroll.scroll - { - self.on_scroll(-1); - } - } - } - FocusedPanel::Files => { - self.file_tree_mut().move_cursor(-1); - } - FocusedPanel::Code => { - let viewport = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() - && scroll.scroll > 0 - { - scroll.on_user_scroll(-1, viewport); - } - } - FocusedPanel::Input => { - self.on_scroll(-1); - } - } - } - (KeyCode::Down, KeyModifiers::NONE) - | (KeyCode::Char('j'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Chat => { - let max_lines = self.auto_scroll.content_len; - if self.chat_cursor.0 + 1 < max_lines { - self.chat_cursor.0 += 1; - // Scroll if cursor moves below viewport - let viewport_bottom = - self.auto_scroll.scroll + self.viewport_height; - if self.chat_cursor.0 >= viewport_bottom { - self.on_scroll(1); - } - } - } - FocusedPanel::Thinking => { - let max_lines = self.thinking_scroll.content_len; - if self.thinking_cursor.0 + 1 < max_lines { - self.thinking_cursor.0 += 1; - let viewport_bottom = self.thinking_scroll.scroll - + self.thinking_viewport_height; - if self.thinking_cursor.0 >= viewport_bottom { - self.on_scroll(1); - } - } - } - FocusedPanel::Files => { - self.file_tree_mut().move_cursor(1); - } - FocusedPanel::Code => { - let viewport = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - let max_lines = scroll.content_len; - if scroll.scroll + viewport < max_lines { - scroll.on_user_scroll(1, viewport); - } - } - } - FocusedPanel::Input => { - self.on_scroll(1); - } - } - } - // Horizontal cursor movement - (KeyCode::Left, KeyModifiers::NONE) - | (KeyCode::Char('h'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Chat => { - if self.chat_cursor.1 > 0 { - self.chat_cursor.1 -= 1; - } - } - FocusedPanel::Thinking => { - if self.thinking_cursor.1 > 0 { - self.thinking_cursor.1 -= 1; - } - } - FocusedPanel::Code => {} - _ => {} - } - } - (KeyCode::Right, KeyModifiers::NONE) - | (KeyCode::Char('l'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Chat => { - if let Some(line) = self.get_line_at_row(self.chat_cursor.0) - { - let max_col = line.chars().count(); - if self.chat_cursor.1 < max_col { - self.chat_cursor.1 += 1; - } - } - } - FocusedPanel::Thinking => { - if let Some(line) = - self.get_line_at_row(self.thinking_cursor.0) - { - let max_col = line.chars().count(); - if self.thinking_cursor.1 < max_col { - self.thinking_cursor.1 += 1; - } - } - } - FocusedPanel::Code => {} - _ => {} - } - } - // Word movement - (KeyCode::Char('w'), KeyModifiers::NONE) => match self.focused_panel { - FocusedPanel::Chat => { - if let Some(new_col) = self.find_next_word_boundary( - self.chat_cursor.0, - self.chat_cursor.1, - ) { - self.chat_cursor.1 = new_col; - } - } - FocusedPanel::Thinking => { - if let Some(new_col) = self.find_next_word_boundary( - self.thinking_cursor.0, - self.thinking_cursor.1, - ) { - self.thinking_cursor.1 = new_col; - } - } - FocusedPanel::Code => {} - _ => {} - }, - (KeyCode::Char('e'), KeyModifiers::NONE) => match self.focused_panel { - FocusedPanel::Chat => { - if let Some(new_col) = - self.find_word_end(self.chat_cursor.0, self.chat_cursor.1) - { - self.chat_cursor.1 = new_col; - } - } - FocusedPanel::Thinking => { - if let Some(new_col) = self.find_word_end( - self.thinking_cursor.0, - self.thinking_cursor.1, - ) { - self.thinking_cursor.1 = new_col; - } - } - FocusedPanel::Code => {} - _ => {} - }, - (KeyCode::Char('b'), KeyModifiers::NONE) => match self.focused_panel { - FocusedPanel::Chat => { - if let Some(new_col) = self.find_prev_word_boundary( - self.chat_cursor.0, - self.chat_cursor.1, - ) { - self.chat_cursor.1 = new_col; - } - } - FocusedPanel::Thinking => { - if let Some(new_col) = self.find_prev_word_boundary( - self.thinking_cursor.0, - self.thinking_cursor.1, - ) { - self.thinking_cursor.1 = new_col; - } - } - FocusedPanel::Code => {} - _ => {} - }, - (KeyCode::Char('^'), KeyModifiers::SHIFT) => match self.focused_panel { - FocusedPanel::Chat => { - if let Some(line) = self.get_line_at_row(self.chat_cursor.0) { - let first_non_blank = line - .chars() - .position(|c| !c.is_whitespace()) - .unwrap_or(0); - self.chat_cursor.1 = first_non_blank; - } - } - FocusedPanel::Thinking => { - if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) - { - let first_non_blank = line - .chars() - .position(|c| !c.is_whitespace()) - .unwrap_or(0); - self.thinking_cursor.1 = first_non_blank; - } - } - FocusedPanel::Code => {} - _ => {} - }, - // Line start/end navigation - (KeyCode::Char('0'), KeyModifiers::NONE) - | (KeyCode::Home, KeyModifiers::NONE) => match self.focused_panel { - FocusedPanel::Chat => { - self.chat_cursor.1 = 0; - } - FocusedPanel::Thinking => { - self.thinking_cursor.1 = 0; - } - FocusedPanel::Code => {} - _ => {} - }, - (KeyCode::Char('$'), KeyModifiers::NONE) => match self.focused_panel { - FocusedPanel::Chat => { - if let Some(line) = self.get_line_at_row(self.chat_cursor.0) { - self.chat_cursor.1 = line.chars().count(); - } - } - FocusedPanel::Thinking => { - if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) - { - self.thinking_cursor.1 = line.chars().count(); - } - } - FocusedPanel::Code => {} - _ => {} - }, - (KeyCode::End, KeyModifiers::NONE) => match self.focused_panel { - FocusedPanel::Chat => { - self.jump_to_bottom(); - } - FocusedPanel::Thinking => { - let viewport_height = self.thinking_viewport_height.max(1); - self.thinking_scroll.jump_to_bottom(viewport_height); - } - FocusedPanel::Files => { - self.file_tree_mut().jump_to_bottom(); - } - FocusedPanel::Code => { - let viewport = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.jump_to_bottom(viewport); - } - } - FocusedPanel::Input => {} - }, - // Half-page scrolling - (KeyCode::Char('d'), KeyModifiers::CONTROL) => { - self.scroll_half_page_down(); - } - (KeyCode::Char('u'), KeyModifiers::CONTROL) => { - self.scroll_half_page_up(); - } - // Full-page scrolling - (KeyCode::Char('f'), KeyModifiers::CONTROL) - | (KeyCode::PageDown, KeyModifiers::NONE) => { - self.scroll_full_page_down(); - } - (KeyCode::Char('b'), KeyModifiers::CONTROL) - | (KeyCode::PageUp, KeyModifiers::NONE) => { - self.scroll_full_page_up(); - } - // Jump to top/bottom - (KeyCode::Char('G'), KeyModifiers::SHIFT) => { - self.jump_to_bottom(); - } - // Yank/paste (works from any panel) - (KeyCode::Char('p'), KeyModifiers::NONE) => { - if !self.clipboard.is_empty() { - // Always paste into Input panel - let current_lines = self.textarea.lines().to_vec(); - let clipboard_lines: Vec = - self.clipboard.lines().map(|s| s.to_string()).collect(); - - // Append clipboard content to current input - let mut new_lines = current_lines; - if new_lines.is_empty() || new_lines == vec![String::new()] { - new_lines = clipboard_lines; - } else { - // Add newline and append - new_lines.push(String::new()); - new_lines.extend(clipboard_lines); - } - - self.textarea = TextArea::new(new_lines); - configure_textarea_defaults(&mut self.textarea); - self.sync_textarea_to_buffer(); - self.status = "Pasted into input".to_string(); - } - } - // Panel switching - (KeyCode::Tab, KeyModifiers::NONE) => { - self.cycle_focus_forward(); - let panel_name = match self.focused_panel { - FocusedPanel::Files => "Files", - FocusedPanel::Chat => "Chat", - FocusedPanel::Thinking => "Thinking", - FocusedPanel::Input => "Input", - FocusedPanel::Code => "Code", - }; - self.status = format!("Focus: {}", panel_name); - } - (KeyCode::BackTab, KeyModifiers::SHIFT) => { - self.cycle_focus_backward(); - let panel_name = match self.focused_panel { - FocusedPanel::Files => "Files", - FocusedPanel::Chat => "Chat", - FocusedPanel::Thinking => "Thinking", - FocusedPanel::Input => "Input", - FocusedPanel::Code => "Code", - }; - self.status = format!("Focus: {}", panel_name); - } - (KeyCode::Char('1'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) - || modifiers.contains(KeyModifiers::ALT) => - { - if self.focus_panel(FocusedPanel::Files) { - self.status = "Focus: Files (Ctrl+1)".to_string(); - self.error = None; - } else if self.is_code_mode() { - self.status = "Files panel is collapsed — use :files to reopen" - .to_string(); - } - return Ok(AppState::Running); - } - (KeyCode::Char('2'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) - || modifiers.contains(KeyModifiers::ALT) => - { - if self.focus_panel(FocusedPanel::Chat) { - self.status = "Focus: Chat (Ctrl+2)".to_string(); - self.error = None; - } - return Ok(AppState::Running); - } - (KeyCode::Char('3'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) - || modifiers.contains(KeyModifiers::ALT) => - { - if self.focus_panel(FocusedPanel::Code) { - self.status = "Focus: Code (Ctrl+3)".to_string(); - self.error = None; - } else { - self.status = - "Open a file to focus the code workspace".to_string(); - } - return Ok(AppState::Running); - } - (KeyCode::Char('4'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) - || modifiers.contains(KeyModifiers::ALT) => - { - if self.focus_panel(FocusedPanel::Thinking) { - self.status = "Focus: Thinking (Ctrl+4)".to_string(); - self.error = None; - } else { - self.status = "No reasoning panel to focus yet".to_string(); - } - return Ok(AppState::Running); - } - (KeyCode::Char('5'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) - || modifiers.contains(KeyModifiers::ALT) => - { - if self.focus_panel(FocusedPanel::Input) { - self.status = - "Focus: Input (Ctrl+5) — press i to edit".to_string(); - self.error = None; - } - return Ok(AppState::Running); - } - (KeyCode::Char('m'), KeyModifiers::NONE) => { - if let Err(err) = self.show_model_picker(None).await { - self.error = Some(err.to_string()); - } - return Ok(AppState::Running); - } - (KeyCode::Esc, KeyModifiers::NONE) => { - self.set_input_mode(InputMode::Normal); - } - _ => {} - } - } - InputMode::RepoSearch => match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { - self.set_input_mode(InputMode::Normal); - self.status = "Normal mode".to_string(); - } - (KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::ALT) => { - self.open_repo_search_scratch().await?; - } - (KeyCode::Enter, _) => { - if self.repo_search.running() { - self.status = "Search already running".to_string(); - } else if self.repo_search.dirty() || !self.repo_search.has_results() { - self.start_repo_search().await?; - } else { - self.open_repo_search_match().await?; - } - } - (KeyCode::Backspace, modifiers) - if !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - self.repo_search.pop_query_char(); - *self.repo_search.status_mut() = - Some("Press Enter to search".to_string()); - self.status = format!("Query: {}", self.repo_search.query_input()); - } - (KeyCode::Char('u'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.repo_search.clear_query(); - *self.repo_search.status_mut() = Some("Query cleared".to_string()); - self.status = "Query cleared".to_string(); - } - (KeyCode::Delete, _) => { - self.repo_search.clear_query(); - *self.repo_search.status_mut() = Some("Query cleared".to_string()); - self.status = "Query cleared".to_string(); - } - (KeyCode::Char(c), modifiers) - if !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) - && !c.is_control() => - { - self.repo_search.push_query_char(c); - *self.repo_search.status_mut() = - Some("Press Enter to search".to_string()); - self.status = format!("Query: {}", self.repo_search.query_input()); - } - (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { - self.repo_search.move_selection(-1); - } - (KeyCode::Down, _) - | (KeyCode::Char('j'), KeyModifiers::NONE) - | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - self.repo_search.move_selection(1); - } - (KeyCode::PageUp, _) => { - self.repo_search.page(-1); - } - (KeyCode::PageDown, _) => { - self.repo_search.page(1); - } - (KeyCode::Home, _) => { - self.repo_search.scroll_to(0); - } - (KeyCode::End, _) => { - let max = self.repo_search.max_scroll(); - self.repo_search.scroll_to(max); - } - _ => {} - }, - InputMode::SymbolSearch => match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { - self.set_input_mode(InputMode::Normal); - self.status = "Normal mode".to_string(); - } - (KeyCode::Enter, _) => { - if self.symbol_search.is_running() { - self.status = "Symbol index still building".to_string(); - } else { - self.open_symbol_search_entry().await?; - } - } - (KeyCode::Backspace, modifiers) - if !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - self.symbol_search.pop_query_char(); - self.status = format!("Symbol filter: {}", self.symbol_search.query()); - } - (KeyCode::Char('u'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.symbol_search.clear_query(); - self.status = "Symbol query cleared".to_string(); - } - (KeyCode::Delete, _) => { - self.symbol_search.clear_query(); - self.status = "Symbol query cleared".to_string(); - } - (KeyCode::Char(c), modifiers) - if !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) - && !c.is_control() => - { - self.symbol_search.push_query_char(c); - self.status = format!("Symbol filter: {}", self.symbol_search.query()); - } - (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { - self.symbol_search.move_selection(-1); - } - (KeyCode::Down, _) - | (KeyCode::Char('j'), KeyModifiers::NONE) - | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - self.symbol_search.move_selection(1); - } - (KeyCode::PageUp, _) => { - self.symbol_search.page(-1); - } - (KeyCode::PageDown, _) => { - self.symbol_search.page(1); - } - _ => {} - }, - InputMode::Editing => { - if self.current_keymap_profile == KeymapProfile::Emacs - && self.handle_emacs_editing_key(&key) - { - return Ok(AppState::Running); - } - - match (key.code, key.modifiers) { - (KeyCode::Char('p'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.sync_textarea_to_buffer(); - self.set_input_mode(InputMode::Command); - self.command_palette.clear(); - self.command_palette.ensure_suggestions(); - self.status = ":".to_string(); - } - (KeyCode::Char('c'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - let _ = self.cancel_active_generation()?; - self.sync_textarea_to_buffer(); - self.set_input_mode(InputMode::Normal); - self.reset_status(); - } - (KeyCode::Esc, KeyModifiers::NONE) => { - // Sync textarea content to input buffer before leaving edit mode - self.sync_textarea_to_buffer(); - self.set_input_mode(InputMode::Normal); - self.reset_status(); - } - (KeyCode::Char('['), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.sync_textarea_to_buffer(); - self.set_input_mode(InputMode::Normal); - self.reset_status(); - } - (KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => { - self.textarea.insert_newline(); - } - (KeyCode::Enter, KeyModifiers::NONE) => { - self.sync_textarea_to_buffer(); - let effects = - self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit)); - self.handle_app_effects(effects).await?; - return Ok(AppState::Running); - } - (KeyCode::Enter, _) => { - // Any Enter with modifiers keeps editing and inserts a newline via tui-textarea - self.textarea.input(Input::from(key)); - } - // History navigation - (KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => { - self.with_input_buffer_mut(|buffer| buffer.history_previous()); - self.sync_buffer_to_textarea(); - } - (KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => { - self.with_input_buffer_mut(|buffer| buffer.history_next()); - self.sync_buffer_to_textarea(); - } - // Vim-style navigation with Ctrl - (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => { - self.textarea.move_cursor(tui_textarea::CursorMove::Head); - } - (KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => { - self.textarea.move_cursor(tui_textarea::CursorMove::End); - } - (KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => { - self.textarea - .move_cursor(tui_textarea::CursorMove::WordForward); - } - (KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => { - self.textarea - .move_cursor(tui_textarea::CursorMove::WordBack); - } - (KeyCode::Tab, m) if m.is_empty() => { - if !self.complete_resource_reference() { - self.textarea.input(Input::from(key)); - } - } - (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { - // Redo - history next - self.with_input_buffer_mut(|buffer| buffer.history_next()); - self.sync_buffer_to_textarea(); - } - _ => { - // Let tui-textarea handle all other input - self.textarea.input(Input::from(key)); - } - } - } - InputMode::Visual => match (key.code, key.modifiers) { - (KeyCode::Esc, _) | (KeyCode::Char('v'), KeyModifiers::NONE) => { - // Cancel selection and return to normal mode - if matches!(self.focused_panel, FocusedPanel::Input) { - self.textarea.cancel_selection(); - } - self.set_input_mode(InputMode::Normal); - self.visual_start = None; - self.visual_end = None; - self.reset_status(); - } - (KeyCode::Char('y'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Input => { - // Yank selected text using tui-textarea's copy - self.textarea.copy(); - // Get the yanked text from textarea's internal clipboard - let yanked = self.textarea.yank_text(); - if !yanked.is_empty() { - self.clipboard = yanked; - self.status = - format!("Yanked {} chars", self.clipboard.len()); - } else { - // Fall back to yanking current line if no selection - let (row, _) = self.textarea.cursor(); - if let Some(line) = self.textarea.lines().get(row) { - self.clipboard = line.clone(); - self.status = format!( - "Yanked line ({} chars)", - self.clipboard.len() - ); - } - } - self.textarea.cancel_selection(); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Yank selected lines from scrollable panels - if let Some(yanked) = self.yank_from_panel() { - self.clipboard = yanked; - self.status = - format!("Yanked {} chars", self.clipboard.len()); - } else { - self.status = "Nothing to yank".to_string(); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - self.set_input_mode(InputMode::Normal); - self.visual_start = None; - self.visual_end = None; - } - (KeyCode::Char('d'), KeyModifiers::NONE) | (KeyCode::Delete, _) => { - match self.focused_panel { - FocusedPanel::Input => { - // Cut (delete) selected text using tui-textarea's cut - if self.textarea.cut() { - // Get the cut text - let cut_text = self.textarea.yank_text(); - self.clipboard = cut_text; - self.sync_textarea_to_buffer(); - self.status = format!("Cut {} chars", self.clipboard.len()); - } else { - self.status = "Nothing to cut".to_string(); - } - self.textarea.cancel_selection(); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Can't delete from read-only panels, just yank - if let Some(yanked) = self.yank_from_panel() { - self.clipboard = yanked; - self.status = format!( - "Yanked {} chars (read-only panel)", - self.clipboard.len() - ); - } else { - self.status = "Nothing to yank".to_string(); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - self.set_input_mode(InputMode::Normal); - self.visual_start = None; - self.visual_end = None; - } - // Movement keys to extend selection - (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Input => { - self.textarea.move_cursor(tui_textarea::CursorMove::Back); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Move selection left (decrease column) - if let Some((row, col)) = self.visual_end - && col > 0 - { - self.visual_end = Some((row, col - 1)); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - } - (KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Input => { - self.textarea.move_cursor(tui_textarea::CursorMove::Forward); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Move selection right (increase column) - if let Some((row, col)) = self.visual_end { - self.visual_end = Some((row, col + 1)); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - } - (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Input => { - self.textarea.move_cursor(tui_textarea::CursorMove::Up); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Move selection up (decrease end row) - if let Some((row, col)) = self.visual_end - && row > 0 - { - self.visual_end = Some((row - 1, col)); - // Scroll if needed to keep selection visible - self.on_scroll(-1); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - } - (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Input => { - self.textarea.move_cursor(tui_textarea::CursorMove::Down); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Move selection down (increase end row) - if let Some((row, col)) = self.visual_end { - // Get max lines for the current panel - let max_lines = - if matches!(self.focused_panel, FocusedPanel::Chat) { - self.auto_scroll.content_len - } else { - self.thinking_scroll.content_len - }; - if row + 1 < max_lines { - self.visual_end = Some((row + 1, col)); - // Scroll if needed to keep selection visible - self.on_scroll(1); - } - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - } - (KeyCode::Char('w'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Input => { - self.textarea - .move_cursor(tui_textarea::CursorMove::WordForward); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Move selection forward by word - if let Some((row, col)) = self.visual_end - && let Some(new_col) = - self.find_next_word_boundary(row, col) - { - self.visual_end = Some((row, new_col)); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - } - (KeyCode::Char('b'), KeyModifiers::NONE) => { - match self.focused_panel { - FocusedPanel::Input => { - self.textarea - .move_cursor(tui_textarea::CursorMove::WordBack); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Move selection backward by word - if let Some((row, col)) = self.visual_end - && let Some(new_col) = - self.find_prev_word_boundary(row, col) - { - self.visual_end = Some((row, new_col)); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - } - (KeyCode::Char('0'), KeyModifiers::NONE) | (KeyCode::Home, _) => { - match self.focused_panel { - FocusedPanel::Input => { - self.textarea.move_cursor(tui_textarea::CursorMove::Head); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Move selection to start of line - if let Some((row, _)) = self.visual_end { - self.visual_end = Some((row, 0)); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - } - (KeyCode::Char('$'), KeyModifiers::NONE) | (KeyCode::End, _) => { - match self.focused_panel { - FocusedPanel::Input => { - self.textarea.move_cursor(tui_textarea::CursorMove::End); - } - FocusedPanel::Chat | FocusedPanel::Thinking => { - // Move selection to end of line - if let Some((row, _)) = self.visual_end - && let Some(line) = self.get_line_at_row(row) - { - let line_len = line.chars().count(); - self.visual_end = Some((row, line_len)); - } - } - FocusedPanel::Files => {} - FocusedPanel::Code => {} - } - } - _ => { - // Ignore all other input in visual mode (no typing allowed) - } - }, - InputMode::Command => match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - self.reset_status(); - } - (KeyCode::Tab, _) => { - // Tab completion - self.complete_command(); - } - (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => { - // Navigate up in suggestions - self.command_palette.select_previous(); - } - (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => { - // Navigate down in suggestions - self.command_palette.select_next(); - } - (KeyCode::Enter, _) => { - // Execute command - let cmd_owned = self.command_palette.buffer().trim().to_string(); - let parts: Vec<&str> = cmd_owned.split_whitespace().collect(); - let command_raw = parts.first().copied().unwrap_or(""); - let args = &parts[1..]; - - if !cmd_owned.is_empty() { - self.command_palette.remember(&cmd_owned); - } - - let bare_command = command_raw.trim_end_matches('!'); - let force = bare_command.len() != command_raw.len(); - - match bare_command { - "" => {} - "wq" | "x" => { - let path_arg = if args.is_empty() { - None - } else { - Some(args.join(" ")) - }; - let result = - self.save_active_code_buffer(path_arg, force).await?; - if matches!(result, SaveStatus::Saved | SaveStatus::NoChanges) { - self.close_active_code_buffer(force); - } - } - "w" | "write" | "save" => { - let path_arg = if args.is_empty() { - None - } else { - Some(args.join(" ")) - }; - let _ = self.save_active_code_buffer(path_arg, force).await?; - } - "q" => { - if matches!(self.focused_panel, FocusedPanel::Files) - && !self.is_file_panel_collapsed() - { - self.collapse_file_panel(); - self.status = "Files panel hidden".to_string(); - self.error = None; - } else { - self.close_active_code_buffer(force); - } - } - "quit" => { - if matches!(self.focused_panel, FocusedPanel::Files) - && !self.is_file_panel_collapsed() - { - self.collapse_file_panel(); - self.status = "Files panel hidden".to_string(); - self.error = None; - } else { - return Ok(AppState::Quit); - } - } - "create" => { - if !self.is_code_mode() { - self.status = "File operations are available in code mode" - .to_string(); - self.error = None; - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - if args.is_empty() { - self.error = Some("Usage: :create ".to_string()); - } else { - let path_arg = args.join(" "); - match self.create_file_from_command(&path_arg) { - Ok(message) => { - self.status = message; - self.error = None; - } - Err(err) => { - self.status = "File creation failed".to_string(); - self.error = Some(err.to_string()); - } - } - } - } - "attach" => { - if args.is_empty() { - self.error = Some("Usage: :attach ".to_string()); - } else { - let path_arg = args.join(" "); - match self.attach_file(&path_arg).await { - Ok(()) => {} - Err(err) => { - self.status = "Attachment failed".to_string(); - self.error = Some(err.to_string()); - } - } - } - } - "attachments" => { - let action = args.first().map(|s| s.to_ascii_lowercase()); - match action.as_deref() { - Some("clear") => { - if self.pending_attachments.is_empty() { - self.status = - "No staged attachments to clear".to_string(); - } else { - self.pending_attachments.clear(); - self.refresh_attachment_gallery(); - self.status = - "Cleared staged attachments".to_string(); - } - self.error = None; - } - Some("next") | Some("n") => { - self.shift_attachment_selection(1); - } - Some("prev") | Some("previous") | Some("p") => { - self.shift_attachment_selection(-1); - } - Some("remove") => { - if args.len() < 2 { - self.error = Some( - "Usage: :attachments remove " - .to_string(), - ); - } else if let Ok(index) = args[1].parse::() { - if index == 0 - || index > self.pending_attachments.len() - { - self.error = Some(format!( - "Attachment index {} out of range", - index - )); - } else { - self.pending_attachments.remove(index - 1); - self.refresh_attachment_gallery(); - self.status = - format!("Removed attachment {}", index); - self.error = None; - } - } else { - self.error = Some( - "Attachment index must be a positive integer" - .to_string(), - ); - } - } - None => { - if self.pending_attachments.is_empty() { - self.status = "No staged attachments".to_string(); - } else { - let list = self - .pending_attachments - .iter() - .enumerate() - .map(|(idx, attachment)| { - format!( - "{}. {}", - idx + 1, - Self::summarize_attachment(attachment) - ) - }) - .collect::>() - .join(" • "); - self.status = format!("Staged attachments: {list}"); - } - self.error = None; - } - Some(other) => { - self.error = Some(format!( - "Unknown attachments command '{}'. Use :attachments, :attachments clear, or :attachments remove .", - other - )); - } - } - } - "repo" => { - if args.is_empty() { - self.error = - Some("Usage: :repo ".to_string()); - self.status = - "Specify :repo template or :repo review".to_string(); - } else { - match args[0].to_ascii_lowercase().as_str() { - "template" => { - let working = args[1..].iter().any(|flag| { - matches!( - flag.to_ascii_lowercase().as_str(), - "--working" | "--unstaged" | "--all" - ) - }); - match self - .repo_commit_template_markdown(working) - .await - { - Ok(markdown) => { - self.push_system_message(format!( - "💡 Commit template suggestion\n\n{}", - markdown - )); - self.notify_new_activity(); - self.status = - "Commit template generated".to_string(); - self.error = None; - } - Err(err) => { - self.error = Some(format!( - "Failed to build commit template: {}", - err - )); - self.status = - "Commit template generation failed" - .to_string(); - } - } - } - "review" => { - let mut idx = 1; - let mut base: Option = None; - let mut head: Option = None; - let mut parse_error: Option = None; - while idx < args.len() { - match args[idx] { - "--base" => { - if let Some(value) = args.get(idx + 1) { - base = Some(value.to_string()); - idx += 2; - } else { - parse_error = Some( - "--base requires a branch name" - .to_string(), - ); - break; - } - } - "--head" => { - if let Some(value) = args.get(idx + 1) { - head = Some(value.to_string()); - idx += 2; - } else { - parse_error = Some( - "--head requires a ref" - .to_string(), - ); - break; - } - } - other => { - parse_error = Some(format!( - "Unknown repo review option '{}'.", - other - )); - break; - } - } - } - - if let Some(message) = parse_error { - self.error = Some(message); - self.status = "Usage: :repo review [--base BRANCH] [--head REF]".to_string(); - } else { - match self - .repo_review_markdown( - base.clone(), - head.clone(), - ) - .await - { - Ok(markdown) => { - self.push_system_message(format!( - "🧾 Repo review summary\n\n{}", - markdown - )); - self.notify_new_activity(); - self.status = format!( - "Review generated for {} ← {}", - head.as_deref().unwrap_or("HEAD"), - base.as_deref() - .unwrap_or("origin/main"), - ); - self.error = None; - } - Err(err) => { - self.error = Some(format!( - "Repo review failed: {}", - err - )); - self.status = - "Unable to generate review" - .to_string(); - } - } - } - } - other => { - self.error = Some(format!( - "Unknown repo command '{}'. Use :repo template or :repo review.", - other - )); - self.status = - "Usage: :repo ".to_string(); - } - } - } - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - "files" | "explorer" => { - if !self.is_code_mode() { - self.status = - "File explorer is available in code mode".to_string(); - self.error = None; - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - let was_collapsed = self.is_file_panel_collapsed(); - self.toggle_file_panel(); - let now_collapsed = self.is_file_panel_collapsed(); - self.error = None; - - if was_collapsed && !now_collapsed { - self.status = "Files panel shown".to_string(); - } else if !was_collapsed && now_collapsed { - self.status = "Files panel hidden".to_string(); - } else { - self.status = "Files panel unchanged".to_string(); - } - } - "markdown" => { - let desired = if let Some(arg) = args.first() { - match arg.to_ascii_lowercase().as_str() { - "on" | "enable" | "enabled" | "true" => Some(true), - "off" | "disable" | "disabled" | "false" => Some(false), - "toggle" => None, - other => { - self.error = Some(format!( - "Unknown markdown option '{}'. Use on, off, or toggle.", - other - )); - self.status = - "Usage: :markdown [on|off|toggle]".to_string(); - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - } - } else { - None - }; - - let enable = - desired.unwrap_or_else(|| !self.render_markdown_enabled()); - self.set_render_markdown(enable); - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - "compress" => { - let subcommand = - args.first().map(|arg| arg.to_ascii_lowercase()); - - match subcommand.as_deref() { - None => { - let (auto_enabled, last_compression) = { - let controller = self.controller_lock_async().await; - ( - controller.config().chat.auto_compress, - controller.last_compression(), - ) - }; - if let Some(report) = last_compression { - let saved = report - .estimated_tokens_before - .saturating_sub(report.estimated_tokens_after); - let saved_fmt = format_token_short(saved as u64); - let before_fmt = format_token_short( - report.estimated_tokens_before as u64, - ); - let after_fmt = format_token_short( - report.estimated_tokens_after as u64, - ); - self.status = format!( - "Auto compression is {}. Last run saved {} tokens ({} → {}).", - if auto_enabled { - "enabled" - } else { - "disabled" - }, - saved_fmt, - before_fmt, - after_fmt - ); - } else { - self.status = format!( - "Auto compression is {}. No compression has run yet.", - if auto_enabled { - "enabled" - } else { - "disabled" - } - ); - } - self.error = None; - } - Some("now") | Some("run") => { - let compression_result = { - let mut controller = - self.controller_lock_async().await; - controller.compress_now().await - }?; - match compression_result { - Some(report) => { - self.handle_compression_report(&report); - } - None => { - self.status = "Conversation is below the compression threshold.".to_string(); - self.error = None; - } - } - } - Some("auto") => { - if args.len() < 2 { - self.error = Some( - "Usage: :compress auto " - .to_string(), - ); - } else { - let mode = args[1].to_ascii_lowercase(); - let current = - self.with_config(|cfg| cfg.chat.auto_compress); - let desired = match mode.as_str() { - "on" | "enable" | "enabled" | "true" => { - Some(true) - } - "off" | "disable" | "disabled" | "false" => { - Some(false) - } - "toggle" => Some(!current), - other => { - self.error = Some(format!( - "Unknown auto setting '{}'. Use on, off, or toggle.", - other - )); - None - } - }; - - if let Some(desired) = desired { - let save_result = self.with_config_mut(|cfg| { - cfg.chat.auto_compress = desired; - config::save_config(cfg) - }); - if let Err(err) = save_result { - self.error = Some(format!( - "Failed to save config: {}", - err - )); - } else { - self.error = None; - if desired { - self.status = - "Auto compression enabled" - .to_string(); - } else { - self.status = - "Auto compression disabled" - .to_string(); - } - } - } - } - } - Some(other) => { - self.error = Some(format!( - "Unknown compress option '{}'. Use :compress, :compress now, or :compress auto .", - other - )); - } - } - - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - "queue" => { - self.run_queue_command(args); - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - "c" | "clear" => { - self.with_conversation_mut(|conversation| conversation.clear()); - self.chat_line_offset = 0; - self.auto_scroll = AutoScroll::default(); - self.clear_new_message_alert(); - self.pending_attachments.clear(); - self.refresh_attachment_gallery(); - self.status = "Conversation cleared".to_string(); - } - "session" => { - if let Some(subcommand) = args.first() { - match subcommand.to_ascii_lowercase().as_str() { - "save" => { - let name = if args.len() > 1 { - Some(args[1..].join(" ")) - } else { - None - }; - let description = if self.with_config(|cfg| { - cfg.storage.generate_descriptions - }) { - self.status = - "Generating description...".to_string(); - let description_result = { - let controller = - self.controller_lock_async().await; - controller - .generate_conversation_description() - .await - }; - description_result.ok() - } else { - None - }; - - let save_result = { - let controller = - self.controller_lock_async().await; - controller - .save_active_session( - name.clone(), - description, - ) - .await - }; - - match save_result { - Ok(id) => { - self.status = if let Some(name) = name { - format!("Session saved: {name} ({id})") - } else { - format!("Session saved with id {id}") - }; - self.error = None; - } - Err(e) => { - self.error = Some(format!( - "Failed to save session: {}", - e - )); - } - } - } - other => { - self.error = Some(format!( - "Unknown session subcommand: {}", - other - )); - } - } - } else { - self.status = - "Session commands: :session save [name]".to_string(); - self.error = None; - } - } - "oauth" => { - if args.is_empty() { - let pending = - self.controller_lock().pending_oauth_servers(); - if pending.is_empty() { - self.status = - "No OAuth-enabled MCP servers require authorization." - .to_string(); - } else { - self.status = format!( - "Pending OAuth servers: {}", - pending.join(", ") - ); - } - self.error = None; - } else if args.len() == 1 { - self.start_oauth_login(args[0]).await?; - } else if args.len() == 2 - && args[0].eq_ignore_ascii_case("login") - { - self.start_oauth_login(args[1]).await?; - } else { - self.error = - Some("Usage: :oauth [login] ".to_string()); - } - } - "load" | "o" => { - // Load saved sessions and enter browser mode - let sessions_result = { - let controller = self.controller_lock_async().await; - controller.list_saved_sessions().await - }; - match sessions_result { - Ok(sessions) => { - self.saved_sessions = sessions; - self.selected_session_index = 0; - self.set_input_mode(InputMode::SessionBrowser); - self.command_palette.clear(); - return Ok(AppState::Running); - } - Err(e) => { - self.error = - Some(format!("Failed to list sessions: {}", e)); - } - }; - } - "open" => { - if let Some(path) = args.first() { - if !matches!( - self.operating_mode, - owlen_core::mode::Mode::Code - ) { - self.error = Some( - "Code view requires code mode. Run :mode code first." - .to_string(), - ); - } else { - let file_content = { - let controller = self.controller_lock_async().await; - controller.read_file_with_tools(path).await - }; - match file_content { - Ok(content) => { - let absolute = - self.absolute_tree_path(Path::new(path)); - self.set_code_view_content( - path.to_string(), - Some(absolute), - content, - ); - self.focused_panel = FocusedPanel::Code; - self.ensure_focus_valid(); - self.status = format!("Opened {}", path); - self.set_system_status(format!( - "Viewing {}", - path - )); - self.error = None; - } - Err(e) => { - self.error = - Some(format!("Failed to open file: {}", e)); - } - } - } - } else { - self.error = Some("Usage: :open ".to_string()); - } - } - "close" => { - if self.has_loaded_code_view() { - self.close_code_view(); - self.status = "Closed code view".to_string(); - self.set_system_status(String::new()); - self.error = None; - } else { - self.status = "No code view active".to_string(); - } - } - "sessions" => { - // List saved sessions - let sessions_result = { - let controller = self.controller_lock_async().await; - controller.list_saved_sessions().await - }; - match sessions_result { - Ok(sessions) => { - self.saved_sessions = sessions; - self.selected_session_index = 0; - self.set_input_mode(InputMode::SessionBrowser); - self.command_palette.clear(); - return Ok(AppState::Running); - } - Err(e) => { - self.error = - Some(format!("Failed to list sessions: {}", e)); - } - } - } - "mode" => { - // Switch mode with argument: :mode chat or :mode code - if args.is_empty() { - self.status = format!( - "Current mode: {}. Usage: :mode ", - self.operating_mode - ); - } else { - let mode_str = args[0]; - match mode_str.parse::() { - Ok(new_mode) => { - self.set_mode(new_mode).await; - } - Err(err) => { - self.error = Some(err); - } - } - } - } - "code" => { - // Shortcut to switch to code mode - self.set_mode(owlen_core::mode::Mode::Code).await; - } - "chat" => { - // Shortcut to switch to chat mode - self.set_mode(owlen_core::mode::Mode::Chat).await; - } - "tools" => { - if args.is_empty() { - // List available tools in current mode and available presets - let available_tools: Vec = { - let config = self.config_async().await; - vec![ - WEB_SEARCH_TOOL_NAME.to_string(), - "code_exec".to_string(), - "file_write".to_string(), - ] - .into_iter() - .filter(|tool| { - config - .modes - .is_tool_allowed(self.operating_mode, tool) - }) - .collect() - }; // config dropped here - - let presets = presets::tier_descriptions(); - let preset_help: Vec = presets - .iter() - .map(|(tier, description)| { - format!("{}: {}", tier.as_str(), description) - }) - .collect(); - - if available_tools.is_empty() { - self.status = format!( - "No tools available in {} mode. Presets: {}", - self.operating_mode, - preset_help.join(" | ") - ); - } else { - self.status = format!( - "Tools in {} mode: {} · Presets: {}", - self.operating_mode, - available_tools.join(", "), - preset_help.join(" | ") - ); - } - } else { - match args[0].to_lowercase().as_str() { - "install" => { - if args.len() < 2 { - self.error = Some( - "Usage: :tools install [--prune]" - .to_string(), - ); - } else { - let preset_name = &args[1]; - let prune = args - .iter() - .skip(2) - .any(|arg| *arg == "--prune"); - match self - .install_tool_preset(preset_name, prune) - .await - { - Ok(message) => { - self.status = message; - self.error = None; - } - Err(err) => { - self.error = Some(err.to_string()); - self.status = - "Preset installation failed" - .to_string(); - } - } - } - } - "audit" => { - let preset_name = args.get(1).copied(); - match self.audit_tool_preset(preset_name).await { - Ok(message) => { - self.status = message; - self.error = None; - } - Err(err) => { - self.error = Some(err.to_string()); - self.status = - "Preset audit failed".to_string(); - } - } - } - other => { - self.error = Some(format!( - "Unknown :tools subcommand '{}'.", - other - )); - } - } - } - } - "h" | "help" => { - self.set_input_mode(InputMode::Help); - self.status = "Help".to_string(); - self.error = None; - self.command_palette.clear(); - return Ok(AppState::Running); - } - "m" | "model" => { - if args.is_empty() { - if let Err(err) = self.show_model_picker(None).await { - self.error = Some(err.to_string()); - } - self.command_palette.clear(); - return Ok(AppState::Running); - } - let subcommand = args[0].to_lowercase(); - match subcommand.as_str() { - "info" | "details" | "refresh" => { - let outcome: Result<()> = match subcommand.as_str() { - "info" => { - let target = if args.len() > 1 { - args[1..].join(" ") - } else { - self.selected_model() - }; - if target.trim().is_empty() { - Err(anyhow!("Usage: :model info ")) - } else { - self.ensure_model_details(&target, false) - .await - } - } - "details" => { - let target = self.selected_model(); - if target.trim().is_empty() { - Err(anyhow!( - "No active model set. Use :model to choose one first" - )) - } else { - self.ensure_model_details(&target, false) - .await - } - } - _ => { - let target = if args.len() > 1 { - args[1..].join(" ") - } else { - self.selected_model() - }; - if target.trim().is_empty() { - Err(anyhow!("Usage: :model refresh ")) - } else { - self.ensure_model_details(&target, true) - .await - } - } - }; - - match outcome { - Ok(_) => self.error = None, - Err(err) => self.error = Some(err.to_string()), - } - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - _ => { - let filter = args.join(" "); - match self.select_model_with_filter(&filter).await { - Ok(_) => self.error = None, - Err(err) => { - self.status = err.to_string(); - self.error = Some(err.to_string()); - } - } - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - } - } - "provider" => { - if args.is_empty() { - self.error = Some("Usage: :provider ".to_string()); - self.status = "Usage: :provider ".to_string(); - } else { - let provider_query = args[0].to_string(); - let mode_arg = args.get(1).map(|value| value.to_string()); - - if let Some(mode_value) = mode_arg { - if let Some(provider) = - self.best_provider_match(&provider_query) - { - match self - .apply_provider_mode(&provider, &mode_value) - .await - { - Ok(_) => { - self.selected_provider = provider.clone(); - self.update_selected_provider_index(); - } - Err(err) => { - self.error = Some(err.to_string()); - self.status = err.to_string(); - } - } - } else { - self.error = Some(format!( - "No provider matching '{}'", - provider_query - )); - self.status = format!( - "No provider matching '{}'", - provider_query.trim() - ); - } - } else { - if self.available_providers.is_empty() - && let Err(err) = self.refresh_models().await - { - self.error = Some(format!( - "Failed to refresh providers: {}", - err - )); - self.status = - "Unable to refresh providers".to_string(); - } - - let filter = provider_query; - if let Some(provider) = - self.best_provider_match(&filter) - { - match self.switch_to_provider(&provider).await { - Ok(_) => { - self.selected_provider = provider.clone(); - self.update_selected_provider_index(); - let save_result = { - let controller = - self.controller_lock_async().await; - controller - .config_mut() - .general - .default_provider = - provider.clone(); - config::save_config( - &controller.config(), - ) - }; - match save_result { - Ok(_) => self.error = None, - Err(err) => { - self.error = Some(format!( - "Provider switched but config save failed: {}", - err - )); - self.status = - "Provider switch saved with warnings" - .to_string(); - } - } - self.status = format!( - "Active provider: {}", - provider - ); - if let Err(err) = - self.refresh_models().await - { - self.error = Some(format!( - "Provider switched but refreshing models failed: {}", - err - )); - self.status = - "Provider switched; failed to refresh models" - .to_string(); - } - self.context_usage = None; - self.refresh_usage_summary().await?; - } - Err(err) => { - self.error = Some(format!( - "Failed to switch provider: {}", - err - )); - self.status = - "Provider switch failed".to_string(); - } - } - } else { - self.error = Some(format!( - "No provider matching '{}'", - filter - )); - self.status = format!( - "No provider matching '{}'", - filter.trim() - ); - } - } - } - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - "limits" => { - self.show_usage_limits().await?; - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - "models" => { - if args.is_empty() { - if let Err(err) = self.show_model_picker(None).await { - self.error = Some(err.to_string()); - } - self.command_palette.clear(); - return Ok(AppState::Running); - } - - match args[0] { - "--local" => { - if let Err(err) = self - .show_model_picker(Some(FilterMode::LocalOnly)) - .await - { - self.error = Some(err.to_string()); - } else if !self - .focus_first_model_in_scope(&ModelScope::Local) - { - self.status = - "No local models available".to_string(); - } else { - self.status = "Showing local models".to_string(); - self.error = None; - } - self.command_palette.clear(); - return Ok(AppState::Running); - } - "--cloud" => { - if let Err(err) = self - .show_model_picker(Some(FilterMode::CloudOnly)) - .await - { - self.error = Some(err.to_string()); - } else if !self - .focus_first_model_in_scope(&ModelScope::Cloud) - { - self.status = - "No cloud models available".to_string(); - } else { - self.status = "Showing cloud models".to_string(); - self.error = None; - } - self.command_palette.clear(); - return Ok(AppState::Running); - } - "--available" => { - if let Err(err) = self - .show_model_picker(Some(FilterMode::Available)) - .await - { - self.error = Some(err.to_string()); - } else if !self.focus_first_available_model() { - self.status = - "No available models right now".to_string(); - } else { - self.status = - "Showing available models".to_string(); - self.error = None; - } - self.command_palette.clear(); - return Ok(AppState::Running); - } - "info" => { - let force_refresh = args - .get(1) - .map(|flag| { - matches!(*flag, "refresh" | "-r" | "--refresh") - }) - .unwrap_or(false); - let outcome = self - .prefetch_all_model_details(force_refresh) - .await; - - match outcome { - Ok(_) => self.error = None, - Err(err) => self.error = Some(err.to_string()), - } - - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - _ => { - self.error = Some( - "Usage: :models [--local|--cloud|info]".to_string(), - ); - self.status = - "Usage: :models [--local|--cloud|info]".to_string(); - } - } - - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - // "run-agent" command removed to break circular dependency on owlen-cli. - "agent" => { - if let Some(subcommand) = args.first() { - match subcommand.to_ascii_lowercase().as_str() { - "status" => { - let armed = - if self.agent_mode { "armed" } else { "idle" }; - let running = if self.agent_running { - "running" - } else { - "stopped" - }; - let agent_label = self - .active_agent_profile() - .map(|profile| { - profile.display_name().to_string() - }) - .unwrap_or_else(|| "(none)".to_string()); - self.status = format!( - "Agent status: {armed} · {running} · active: {agent_label}" - ); - self.error = None; - } - "list" => { - let listing = self.describe_agents(); - self.status = listing - .lines() - .next() - .unwrap_or("No agent profiles found.") - .to_string(); - self.error = None; - self.push_toast_with_hint( - ToastLevel::Info, - listing, - ":agent use ", - ); - } - "use" => { - if args.len() < 2 { - self.error = - Some("Usage: :agent use ".to_string()); - } else { - let target = args[1..].join(" "); - if let Err(err) = - self.set_active_agent_from_query(&target) - { - self.error = Some(err.to_string()); - self.status = - "Failed to select agent".to_string(); - } - } - } - "reload" => match self.agent_registry.reload() { - Ok(()) => { - if self - .active_agent_id - .as_deref() - .and_then(|id| self.agent_registry.get(id)) - .is_none() - { - self.active_agent_id = None; - self.set_system_status( - "🤖 Idle".to_string(), - ); - } else if let Some(profile) = - self.active_agent_profile() - { - self.set_system_status(format!( - "🤖 Ready · {}", - profile.display_name() - )); - } - let count = - self.agent_registry.profiles().len(); - self.status = format!( - "Reloaded agent profiles ({count})" - ); - self.error = None; - } - Err(err) => { - let message = - format!("Failed to reload agents: {err}"); - self.error = Some(message.clone()); - self.status = "Agent reload failed".to_string(); - self.push_toast(ToastLevel::Error, message); - } - }, - "start" | "arm" => { - if self.agent_running { - self.status = - "Agent is already running".to_string(); - } else if let Err(err) = self.ensure_active_agent() - { - self.error = Some(err.to_string()); - } else if let Some(display_name) = self - .active_agent_profile() - .map(|p| p.display_name().to_string()) - { - self.agent_mode = true; - self.status = format!( - "Agent '{}' armed. Next message will run it.", - display_name - ); - self.error = None; - self.set_system_status(format!( - "🤖 Ready · {}", - display_name - )); - } - } - "stop" | "disarm" => { - if self.agent_running { - self.agent_running = false; - self.agent_mode = false; - self.agent_actions = None; - self.status = - "Agent execution stopped".to_string(); - self.error = None; - self.set_system_status("🤖 Idle".to_string()); - } else if self.agent_mode { - self.agent_mode = false; - self.agent_actions = None; - self.status = "Agent disarmed".to_string(); - self.error = None; - self.set_system_status("🤖 Idle".to_string()); - } else { - self.status = - "No agent is currently running".to_string(); - } - } - other => { - self.error = - Some(format!("Unknown agent command: {other}")); - } - } - } else if self.agent_running { - self.status = "Agent is already running".to_string(); - } else if let Err(err) = self.ensure_active_agent() { - self.error = Some(err.to_string()); - } else if let Some(display_name) = self - .active_agent_profile() - .map(|p| p.display_name().to_string()) - { - self.agent_mode = true; - self.status = format!( - "Agent '{}' armed. Next message will be processed by the agent.", - display_name - ); - self.error = None; - self.set_system_status(format!( - "🤖 Ready · {}", - display_name - )); - } - self.set_input_mode(InputMode::Normal); - self.command_palette.clear(); - return Ok(AppState::Running); - } - "stop-agent" => { - if self.agent_running { - self.agent_running = false; - self.agent_mode = false; - self.agent_actions = None; - self.status = "Agent execution stopped".to_string(); - self.error = None; - } else if self.agent_mode { - self.agent_mode = false; - self.agent_actions = None; - self.status = "Agent disarmed".to_string(); - self.error = None; - } else { - self.status = "No agent is currently running".to_string(); - } - } - "n" | "new" => { - { - let mut controller = self.controller_lock(); - controller.start_new_conversation(None, None); - } - self.reset_after_new_conversation()?; - self.status = "Started new conversation".to_string(); - self.error = None; - } - "e" | "edit" => { - if let Some(path) = args.first() { - let read_result = { - let controller = self.controller_lock_async().await; - controller.read_file(path).await - }; - match read_result { - Ok(content) => { - let message = format!( - "The content of file `{}` is:\n```\n{}\n```", - path, content - ); - self.push_user_message(message); - self.pending_llm_request = true; - } - Err(e) => { - self.error = - Some(format!("Failed to read file: {}", e)); - } - }; - } else { - self.error = Some("Usage: :e ".to_string()); - } - } - "ls" => { - let path = args.first().copied().unwrap_or("."); - let list_result = { - let controller = self.controller_lock_async().await; - controller.list_dir(path).await - }; - match list_result { - Ok(entries) => { - let message = format!( - "Directory listing for `{}`:\n```\n{}\n```", - path, - entries.join("\n") - ); - self.push_user_message(message); - } - Err(e) => { - self.error = - Some(format!("Failed to list directory: {}", e)); - } - }; - } - "accessibility" => { - if let Err(err) = self.handle_accessibility_command(args) { - self.error = Some(format!( - "Failed to update accessibility settings: {}", - err - )); - } - } - "theme" => { - if args.is_empty() { - self.error = Some("Usage: :theme ".to_string()); - } else { - let theme_name = args.join(" "); - match self.switch_theme(&theme_name) { - Ok(_) => { - // Success message already set by switch_theme - } - Err(_) => { - // Error message already set by switch_theme - } - } - } - } - "tutorial" => { - self.show_tutorial(); - } - "themes" => { - // Load all themes and enter browser mode - let themes = owlen_core::load_all_themes(); - let mut theme_list: Vec = - themes.keys().cloned().collect(); - theme_list.sort(); - - self.available_themes = theme_list; - - // Set selected index to current theme - let current_theme = if self.accessibility_high_contrast { - &self.base_theme_name - } else { - &self.theme.name - }; - self.selected_theme_index = self - .available_themes - .iter() - .position(|name| name == current_theme) - .unwrap_or(0); - - self.set_input_mode(InputMode::ThemeBrowser); - self.command_palette.clear(); - return Ok(AppState::Running); - } - "layout" => { - if let Some(subcommand) = args.first() { - match subcommand.to_lowercase().as_str() { - "save" => { - if self.code_workspace.tabs().is_empty() { - self.status = - "No open panes to save".to_string(); - self.error = None; - self.push_toast( - ToastLevel::Warning, - "Open a pane before saving layout.", - ); - } else { - self.persist_workspace_layout(); - self.status = - "Workspace layout saved".to_string(); - self.error = None; - self.push_toast( - ToastLevel::Success, - "Workspace layout saved.", - ); - } - } - "load" => match self.restore_workspace_layout().await { - Ok(true) => { - self.status = - "Workspace layout restored".to_string(); - self.error = None; - self.push_toast( - ToastLevel::Success, - "Workspace layout restored.", - ); - } - Ok(false) => { - self.status = - "No saved layout to restore".to_string(); - self.error = None; - self.push_toast( - ToastLevel::Info, - "No saved layout was found.", - ); - } - Err(err) => { - let message = format!( - "Failed to restore workspace layout: {}", - err - ); - self.error = Some(message.clone()); - self.status = - "Failed to restore workspace layout" - .to_string(); - self.push_toast(ToastLevel::Error, message); - } - }, - other => { - self.status = - format!("Unknown layout command: {other}"); - self.error = Some(format!( - "Unknown layout subcommand: {other}" - )); - } - } - } else { - self.status = "Usage: :layout ".to_string(); - } - } - "reload" => { - // Reload config - match owlen_core::config::Config::load(None) { - Ok(new_config) => { - // Update controller config - self.with_config_mut(|cfg| *cfg = new_config.clone()); - - // Reload theme based on updated config - let theme_name = &new_config.ui.theme; - if let Some(new_theme) = - owlen_core::get_theme(theme_name) - { - self.theme = new_theme; - self.status = format!( - "Configuration and theme reloaded (theme: {})", - theme_name - ); - } else { - self.status = "Configuration reloaded, but theme not found. Using current theme.".to_string(); - } - self.error = None; - self.sync_ui_preferences_from_config(); - self.update_command_palette_catalog(); - if let Err(err) = self.refresh_resource_catalog().await - { - self.push_toast( - ToastLevel::Error, - format!( - "Failed to refresh MCP resources: {}", - err - ), - ); - } - if let Err(err) = - self.refresh_mcp_slash_commands().await - { - self.push_toast( - ToastLevel::Error, - format!( - "Failed to refresh MCP slash commands: {}", - err - ), - ); - } - } - Err(e) => { - self.error = - Some(format!("Failed to reload config: {}", e)); - } - } - } - "cloud" => { - match self.handle_cloud_command(args).await { - Ok(_) => { - if self.error.is_some() { - // leave existing error - } else { - self.error = None; - } - } - Err(err) => { - let message = err.to_string(); - if self.status.trim().is_empty() { - self.status = message.clone(); - } - self.error = Some(message); - } - } - self.command_palette.clear(); - self.set_input_mode(InputMode::Normal); - return Ok(AppState::Running); - } - "web" => { - let action = - args.first().map(|value| value.to_ascii_lowercase()); - match action.as_deref() { - Some("on") | Some("enable") => { - match self.set_web_tool_enabled(true).await { - Ok(_) => { - self.status = "Web search enabled; remote lookups allowed." - .to_string(); - self.error = None; - self.push_toast( - ToastLevel::Info, - "Web search enabled; remote lookups allowed.", - ); - } - Err(err) => { - self.status = - "Failed to enable web search".to_string(); - self.error = Some(format!( - "Failed to enable web search: {}", - err - )); - } - } - } - Some("off") | Some("disable") => { - match self.set_web_tool_enabled(false).await { - Ok(_) => { - self.status = - "Web search disabled; staying local." - .to_string(); - self.error = None; - self.push_toast( - ToastLevel::Warning, - "Web search disabled; staying local.", - ); - } - Err(err) => { - self.status = - "Failed to disable web search".to_string(); - self.error = Some(format!( - "Failed to disable web search: {}", - err - )); - } - } - } - Some("status") | None => { - let enabled = self.with_config(|cfg| { - cfg.tools.web_search.enabled - && cfg.privacy.enable_remote_search - }); - if enabled { - self.status = "Web search is enabled.".to_string(); - } else { - self.status = "Web search is disabled.".to_string(); - } - self.error = None; - } - _ => { - self.status = "Usage: :web ".to_string(); - self.error = - Some("Usage: :web ".to_string()); - } - } - self.command_palette.clear(); - self.set_input_mode(InputMode::Normal); - return Ok(AppState::Running); - } - "privacy-enable" => { - if let Some(tool) = args.first() { - let enable_result = { - let mut controller = self.controller_lock_async().await; - controller.set_tool_enabled(tool, true).await - }; - match enable_result { - Ok(_) => { - if let Err(err) = - self.with_config(config::save_config) - { - self.error = Some(format!( - "Enabled {tool}, but failed to save config: {err}" - )); - } else { - self.status = format!("Enabled tool: {tool}"); - self.error = None; - } - } - Err(e) => { - self.error = - Some(format!("Failed to enable tool: {}", e)); - } - } - } else { - self.error = - Some("Usage: :privacy-enable ".to_string()); - } - } - "privacy-disable" => { - if let Some(tool) = args.first() { - let disable_result = { - let mut controller = self.controller_lock_async().await; - controller.set_tool_enabled(tool, false).await - }; - match disable_result { - Ok(_) => { - if let Err(err) = - self.with_config(config::save_config) - { - self.error = Some(format!( - "Disabled {tool}, but failed to save config: {err}" - )); - } else { - self.status = format!("Disabled tool: {tool}"); - self.error = None; - } - } - Err(e) => { - self.error = - Some(format!("Failed to disable tool: {}", e)); - } - } - } else { - self.error = - Some("Usage: :privacy-disable ".to_string()); - } - } - "privacy-clear" => { - let clear_result = { - let controller = self.controller_lock_async().await; - controller.clear_secure_data().await - }; - match clear_result { - Ok(_) => { - self.status = "Cleared secure stored data".to_string(); - self.error = None; - } - Err(e) => { - self.error = - Some(format!("Failed to clear secure data: {}", e)); - } - }; - } - "keymap" => { - if let Some(arg) = args.first().copied() { - if arg.eq_ignore_ascii_case("show") - || arg.eq_ignore_ascii_case("list") - { - let mut lines = - String::from("Active keymap bindings:\n"); - lines.push_str("Mode Sequence Command\n"); - lines.push_str("------------------------------------------------\n"); - for binding in self.keymap.describe_bindings() { - let mode_label = format!("{:?}", binding.mode); - let sequence = if binding.sequence.is_empty() { - String::from("") - } else { - binding.sequence.join(" ") - }; - lines.push_str(&format!( - "{:<14} {:<24} {}\n", - mode_label, sequence, binding.command - )); - } - self.push_system_message(lines); - self.status = "Keymap bindings listed in conversation" - .to_string(); - self.error = None; - } else { - match KeymapProfile::from_str(arg) { - Some(profile) if profile.is_builtin() => { - self.switch_keymap_profile(profile).await?; - } - Some(_) => { - self.error = Some( - "Custom keymaps must be configured via keymap_path".to_string(), - ); - } - None => { - self.error = Some(format!( - "Unknown keymap profile: {}", - arg - )); - } - } - } - } else { - self.status = format!( - "Active keymap: {}", - self.current_keymap_profile().label() - ); - self.error = None; - } - } - _ => { - self.error = Some(format!("Unknown command: {}", cmd_owned)); - } - } - self.command_palette.clear(); - self.set_input_mode(InputMode::Normal); - } - (KeyCode::Char(c), KeyModifiers::NONE) - | (KeyCode::Char(c), KeyModifiers::SHIFT) => { - self.command_palette.push_char(c); - self.status = format!(":{}", self.command_palette.buffer()); - } - (KeyCode::Backspace, _) => { - self.command_palette.pop_char(); - self.status = format!(":{}", self.command_palette.buffer()); - } - _ => {} - }, - InputMode::ProviderSelection => match key.code { - KeyCode::Esc => { - self.set_input_mode(InputMode::Normal); - } - KeyCode::Enter => { - if let Some(provider) = - self.available_providers.get(self.selected_provider_index) - { - self.selected_provider = provider.clone(); - // Update model selection based on new provider (await async) - self.sync_selected_model_index().await; // Update model selection based on new provider - self.set_input_mode(InputMode::ModelSelection); - } - } - KeyCode::Up => { - if self.selected_provider_index > 0 { - self.selected_provider_index -= 1; - } - } - KeyCode::Down => { - if self.selected_provider_index + 1 < self.available_providers.len() { - self.selected_provider_index += 1; - } - } - _ => {} - }, - InputMode::ModelSelection => match key.code { - KeyCode::Esc => { - if self.show_model_info { - self.set_model_info_visible(false); - self.status = "Closed model info panel".to_string(); - } else { - self.set_input_mode(InputMode::Normal); - } - } - KeyCode::Enter => { - if let Some(item) = self.current_model_selector_item() { - match item.kind() { - ModelSelectorItemKind::Header { - provider, expanded, .. - } => { - if *expanded { - let provider_name = provider.clone(); - self.collapse_provider(&provider_name); - self.status = - format!("Collapsed provider: {}", provider_name); - } else { - let provider_name = provider.clone(); - self.expand_provider(&provider_name, true); - self.status = - format!("Expanded provider: {}", provider_name); - } - self.error = None; - } - ModelSelectorItemKind::Scope { provider, .. } => { - let provider_name = provider.clone(); - self.expand_provider(&provider_name, false); - self.status = - format!("Expanded provider: {}", provider_name); - self.error = None; - } - ModelSelectorItemKind::Model { .. } => { - if let Some(model) = self.selected_model_info().cloned() { - if self.apply_model_selection(model).await.is_err() { - // apply_model_selection already sets status/error - } - } else { - self.error = Some( - "No model available for the selected provider" - .to_string(), - ); - } - } - ModelSelectorItemKind::Empty { provider, .. } => { - let provider_name = provider.clone(); - self.collapse_provider(&provider_name); - self.status = - format!("Collapsed provider: {}", provider_name); - self.error = None; - } - } - } - } - KeyCode::Char('q') => { - if self.show_model_info { - self.set_model_info_visible(false); - self.status = "Closed model info panel".to_string(); - } else { - self.set_input_mode(InputMode::Normal); - } - } - KeyCode::Char('i') => { - if let Some(model) = self.selected_model_info() { - let model_id = model.id.clone(); - if let Err(err) = self.ensure_model_details(&model_id, false).await - { - self.error = - Some(format!("Failed to load model info: {}", err)); - } - } - } - KeyCode::Char('r') => { - if let Some(model) = self.selected_model_info() { - let model_id = model.id.clone(); - if let Err(err) = self.ensure_model_details(&model_id, true).await { - self.error = - Some(format!("Failed to refresh model info: {}", err)); - } else { - self.error = None; - } - } - } - KeyCode::Char('j') => { - if self.show_model_info && self.model_info_viewport_height > 0 { - self.model_info_panel - .scroll_down(self.model_info_viewport_height); - } else { - self.move_model_selection(1); - } - } - KeyCode::Char('k') => { - if self.show_model_info && self.model_info_viewport_height > 0 { - self.model_info_panel.scroll_up(); - } else { - self.move_model_selection(-1); - } - } - KeyCode::Up => { - self.move_model_selection(-1); - } - KeyCode::Down => { - self.move_model_selection(1); - } - KeyCode::Left => { - if let Some(item) = self.current_model_selector_item() { - match item.kind() { - ModelSelectorItemKind::Header { - provider, expanded, .. - } => { - if *expanded { - let provider_name = provider.clone(); - self.collapse_provider(&provider_name); - self.status = - format!("Collapsed provider: {}", provider_name); - self.error = None; - } - } - ModelSelectorItemKind::Scope { provider, .. } => { - let provider_name = provider.clone(); - self.collapse_provider(&provider_name); - self.status = - format!("Collapsed provider: {}", provider_name); - self.error = None; - } - ModelSelectorItemKind::Model { provider, .. } => { - if let Some(idx) = self.index_of_header(provider) { - self.set_selected_model_item(idx); - } - } - ModelSelectorItemKind::Empty { provider, .. } => { - let provider_name = provider.clone(); - self.collapse_provider(&provider_name); - self.status = - format!("Collapsed provider: {}", provider_name); - self.error = None; - } - } - } - } - KeyCode::Right => { - if let Some(item) = self.current_model_selector_item() { - match item.kind() { - ModelSelectorItemKind::Header { - provider, expanded, .. - } => { - if !expanded { - let provider_name = provider.clone(); - self.expand_provider(&provider_name, true); - self.status = - format!("Expanded provider: {}", provider_name); - self.error = None; - } - } - ModelSelectorItemKind::Empty { provider, .. } => { - let provider_name = provider.clone(); - self.expand_provider(&provider_name, false); - self.status = - format!("Expanded provider: {}", provider_name); - self.error = None; - } - _ => {} - } - } - } - KeyCode::Char(' ') => { - if let Some(item) = self.current_model_selector_item() - && let ModelSelectorItemKind::Header { - provider, expanded, .. - } = item.kind() - { - if *expanded { - let provider_name = provider.clone(); - self.collapse_provider(&provider_name); - self.status = - format!("Collapsed provider: {}", provider_name); - } else { - let provider_name = provider.clone(); - self.expand_provider(&provider_name, true); - self.status = - format!("Expanded provider: {}", provider_name); - } - self.error = None; - } - } - KeyCode::Backspace => { - self.pop_model_search_char(); - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.clear_model_search_query(); - } - KeyCode::Char(c) - if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => - { - self.push_model_search_char(c); - } - _ => {} - }, - InputMode::Help => match self.guidance_overlay { - GuidanceOverlay::Onboarding => match key.code { - KeyCode::Esc | KeyCode::Char('q') | KeyCode::F(1) => { - self.finish_onboarding(false); - } - KeyCode::Enter - | KeyCode::Char(' ') - | KeyCode::Right - | KeyCode::Char('l') - | KeyCode::Tab => { - self.advance_onboarding_step(); - } - KeyCode::Left | KeyCode::Char('h') | KeyCode::BackTab => { - self.regress_onboarding_step(); - } - _ => {} - }, - GuidanceOverlay::CheatSheet => match key.code { - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::F(1) => { - self.reset_status(); - self.set_input_mode(InputMode::Normal); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - if HELP_TAB_COUNT > 0 { - if self.help_tab_index + 1 < HELP_TAB_COUNT { - self.help_tab_index += 1; - } else { - self.help_tab_index = HELP_TAB_COUNT - 1; - } - } - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - if self.help_tab_index > 0 { - self.help_tab_index -= 1; - } - } - KeyCode::Char(ch) if ch.is_ascii_digit() => { - if let Some(idx) = ch.to_digit(10) - && idx >= 1 && (idx as usize) <= HELP_TAB_COUNT - { - self.help_tab_index = (idx - 1) as usize; - } - } - _ => {} - }, - }, - InputMode::SessionBrowser => match key.code { - KeyCode::Esc => { - self.set_input_mode(InputMode::Normal); - } - KeyCode::Enter => { - // Load selected session - if let Some(session) = - self.saved_sessions.get(self.selected_session_index) - { - let load_result = { - let mut controller = self.controller_lock_async().await; - controller.load_saved_session(session.id).await - }; - match load_result { - Ok(_) => { - self.status = format!( - "Loaded session: {}", - session.name.as_deref().unwrap_or("Unnamed"), - ); - self.error = None; - self.update_thinking_from_last_message(); - self.message_line_cache.clear(); - self.chat_line_offset = 0; - } - Err(e) => { - self.error = Some(format!("Failed to load session: {}", e)); - } - }; - } - self.set_input_mode(InputMode::Normal); - } - KeyCode::Up | KeyCode::Char('k') => { - if self.selected_session_index > 0 { - self.selected_session_index -= 1; - } - } - KeyCode::Down | KeyCode::Char('j') => { - if self.selected_session_index + 1 < self.saved_sessions.len() { - self.selected_session_index += 1; - } - } - KeyCode::Char('d') => { - // Delete selected session - if let Some(session) = - self.saved_sessions.get(self.selected_session_index) - { - let delete_result = { - let controller = self.controller_lock_async().await; - controller.delete_session(session.id).await - }; - match delete_result { - Ok(_) => { - self.saved_sessions.remove(self.selected_session_index); - if self.selected_session_index >= self.saved_sessions.len() - && !self.saved_sessions.is_empty() - { - self.selected_session_index = - self.saved_sessions.len() - 1; - } - self.status = "Session deleted".to_string(); - } - Err(e) => { - self.error = - Some(format!("Failed to delete session: {}", e)); - } - }; - } - } - _ => {} - }, - InputMode::ThemeBrowser => match key.code { - KeyCode::Esc | KeyCode::Char('q') => { - self.set_input_mode(InputMode::Normal); - } - KeyCode::Enter => { - // Apply selected theme - if let Some(theme_name) = self - .available_themes - .get(self.selected_theme_index) - .cloned() - { - match self.switch_theme(&theme_name) { - Ok(_) => { - // Success message already set by switch_theme - } - Err(_) => { - // Error message already set by switch_theme - } - } - } - self.set_input_mode(InputMode::Normal); - } - KeyCode::Up | KeyCode::Char('k') => { - if self.selected_theme_index > 0 { - self.selected_theme_index -= 1; - } - } - KeyCode::Down | KeyCode::Char('j') => { - if self.selected_theme_index + 1 < self.available_themes.len() { - self.selected_theme_index += 1; - } - } - KeyCode::Home | KeyCode::Char('g') => { - self.selected_theme_index = 0; - } - KeyCode::End | KeyCode::Char('G') => { - if !self.available_themes.is_empty() { - self.selected_theme_index = self.available_themes.len() - 1; - } - } - _ => {} - }, - } - } - } - - Ok(AppState::Running) - } - - fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result { - if self.has_pending_consent() { - return Ok(AppState::Running); - } - - let region = self.region_for_position(mouse.column, mouse.row); - - match mouse.kind { - MouseEventKind::ScrollUp => { - if let Some(region) = region { - self.handle_mouse_scroll(region, -MOUSE_SCROLL_STEP); - } - } - MouseEventKind::ScrollDown => { - if let Some(region) = region { - self.handle_mouse_scroll(region, MOUSE_SCROLL_STEP); - } - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(region) = region { - self.handle_mouse_click(region, mouse.column, mouse.row); - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if matches!(region, Some(UiRegion::Input)) { - self.handle_mouse_click(UiRegion::Input, mouse.column, mouse.row); - } - } - _ => {} - } - - Ok(AppState::Running) - } - - fn handle_mouse_scroll(&mut self, region: UiRegion, amount: isize) { - if amount == 0 { - return; - } - - match region { - UiRegion::FileTree => { - if self.focus_panel(FocusedPanel::Files) { - self.file_tree_mut().move_cursor(amount); - } - } - UiRegion::Thinking | UiRegion::Actions => { - if self.focus_panel(FocusedPanel::Thinking) { - let viewport = self.thinking_viewport_height.max(1); - self.thinking_scroll.on_user_scroll(amount, viewport); - } - } - UiRegion::Attachments => { - let delta = if amount > 0 { 1 } else { -1 }; - self.shift_attachment_selection(delta); - } - UiRegion::Code => { - if self.focus_panel(FocusedPanel::Code) { - let viewport = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.on_user_scroll(amount, viewport); - } - } - } - UiRegion::ModelInfo => { - self.scroll_model_info(amount); - } - UiRegion::Input => {} - UiRegion::System - | UiRegion::Status - | UiRegion::Chat - | UiRegion::Content - | UiRegion::Frame - | UiRegion::Header => { - if self.focus_panel(FocusedPanel::Chat) { - self.auto_scroll - .on_user_scroll(amount, self.viewport_height); - self.update_new_message_alert_after_scroll(); - } - } - } - } - - fn handle_mouse_click(&mut self, region: UiRegion, column: u16, row: u16) { - match region { - UiRegion::FileTree => { - self.focus_panel(FocusedPanel::Files); - self.set_input_mode(InputMode::Normal); - } - UiRegion::Thinking | UiRegion::Actions => { - if self.focus_panel(FocusedPanel::Thinking) { - self.set_input_mode(InputMode::Normal); - } - } - UiRegion::Attachments => { - if let Some(rect) = self.last_layout.attachments_panel - && row > rect.y + 1 - { - let list_index = row.saturating_sub(rect.y + 1) as usize; - if list_index < self.attachment_preview_entries.len() { - self.attachment_preview_selection = list_index; - } - } - self.shift_attachment_selection(0); - } - UiRegion::Code => { - if self.focus_panel(FocusedPanel::Code) { - self.set_input_mode(InputMode::Normal); - } - } - UiRegion::Input => { - self.focus_panel(FocusedPanel::Input); - self.set_input_mode(InputMode::Editing); - if let Some(rect) = self.last_layout.input_panel - && let Some((line, column)) = self.input_cursor_from_point(rect, column, row) - { - let line = line.min(u16::MAX as usize) as u16; - let column = column.min(u16::MAX as usize) as u16; - self.textarea.move_cursor(CursorMove::Jump(line, column)); - } - } - UiRegion::ModelInfo => { - self.set_input_mode(InputMode::Normal); - } - UiRegion::System - | UiRegion::Status - | UiRegion::Chat - | UiRegion::Content - | UiRegion::Frame - | UiRegion::Header => { - self.focus_panel(FocusedPanel::Chat); - self.set_input_mode(InputMode::Normal); - } - } - } - - fn input_cursor_from_point(&self, rect: Rect, column: u16, row: u16) -> Option<(usize, usize)> { - let lines = self.textarea.lines(); - if lines.is_empty() { - return Some((0, 0)); - } - - let inner_x = usize::from(column.saturating_sub(rect.x.saturating_add(1))); - let inner_y = usize::from(row.saturating_sub(rect.y.saturating_add(1))); - - let max_line = lines.len().saturating_sub(1); - let line_index = inner_y.min(max_line); - let column_index = Self::grapheme_index_for_visual_offset(&lines[line_index], inner_x); - Some((line_index, column_index)) - } - - fn scroll_model_info(&mut self, amount: isize) { - if amount == 0 { - return; - } - - let steps = amount.unsigned_abs(); - let viewport = self.model_info_viewport_height.max(1); - if amount.is_positive() { - for _ in 0..steps { - self.model_info_panel.scroll_down(viewport); - } - } else { - for _ in 0..steps { - self.model_info_panel.scroll_up(); - } - } - } - - fn grapheme_index_for_visual_offset(line: &str, offset: usize) -> usize { - let mut width = 0usize; - for (idx, grapheme) in line.graphemes(true).enumerate() { - let grapheme_width = UnicodeWidthStr::width(grapheme); - if width + grapheme_width > offset { - return idx; - } - width += grapheme_width; - } - line.graphemes(true).count() - } - - /// Call this when processing scroll up/down keys - pub fn on_scroll(&mut self, delta: isize) { - match self.focused_panel { - FocusedPanel::Chat => { - self.auto_scroll.on_user_scroll(delta, self.viewport_height); - self.update_new_message_alert_after_scroll(); - } - FocusedPanel::Thinking => { - // Ensure we have a valid viewport height - let viewport_height = self.thinking_viewport_height.max(1); - self.thinking_scroll.on_user_scroll(delta, viewport_height); - } - FocusedPanel::Files => { - self.file_tree_mut().move_cursor(delta); - } - FocusedPanel::Code => { - let viewport_height = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.on_user_scroll(delta, viewport_height); - } - } - FocusedPanel::Input => { - // Input panel doesn't scroll - } - } - } - - /// Scroll down half page - pub fn scroll_half_page_down(&mut self) { - match self.focused_panel { - FocusedPanel::Chat => { - self.auto_scroll.scroll_half_page_down(self.viewport_height); - self.update_new_message_alert_after_scroll(); - } - FocusedPanel::Thinking => { - let viewport_height = self.thinking_viewport_height.max(1); - self.thinking_scroll.scroll_half_page_down(viewport_height); - } - FocusedPanel::Files => { - self.file_tree_mut().page_down(); - } - FocusedPanel::Code => { - let viewport_height = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.scroll_half_page_down(viewport_height); - } - } - FocusedPanel::Input => {} - } - } - - /// Scroll up half page - pub fn scroll_half_page_up(&mut self) { - match self.focused_panel { - FocusedPanel::Chat => { - self.auto_scroll.scroll_half_page_up(self.viewport_height); - self.update_new_message_alert_after_scroll(); - } - FocusedPanel::Thinking => { - let viewport_height = self.thinking_viewport_height.max(1); - self.thinking_scroll.scroll_half_page_up(viewport_height); - } - FocusedPanel::Files => { - self.file_tree_mut().page_up(); - } - FocusedPanel::Code => { - let viewport_height = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.scroll_half_page_up(viewport_height); - } - } - FocusedPanel::Input => {} - } - } - - /// Scroll down full page - pub fn scroll_full_page_down(&mut self) { - match self.focused_panel { - FocusedPanel::Chat => { - self.auto_scroll.scroll_full_page_down(self.viewport_height); - self.update_new_message_alert_after_scroll(); - } - FocusedPanel::Thinking => { - let viewport_height = self.thinking_viewport_height.max(1); - self.thinking_scroll.scroll_full_page_down(viewport_height); - } - FocusedPanel::Files => { - self.file_tree_mut().page_down(); - } - FocusedPanel::Code => { - let viewport_height = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.scroll_full_page_down(viewport_height); - } - } - FocusedPanel::Input => {} - } - } - - /// Scroll up full page - pub fn scroll_full_page_up(&mut self) { - match self.focused_panel { - FocusedPanel::Chat => { - self.auto_scroll.scroll_full_page_up(self.viewport_height); - self.update_new_message_alert_after_scroll(); - } - FocusedPanel::Thinking => { - let viewport_height = self.thinking_viewport_height.max(1); - self.thinking_scroll.scroll_full_page_up(viewport_height); - } - FocusedPanel::Files => { - self.file_tree_mut().page_up(); - } - FocusedPanel::Code => { - let viewport_height = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.scroll_full_page_up(viewport_height); - } - } - FocusedPanel::Input => {} - } - } - - /// Jump to top of focused panel - pub fn jump_to_top(&mut self) { - match self.focused_panel { - FocusedPanel::Chat => { - self.auto_scroll.jump_to_top(); - self.chat_cursor = (0, 0); - } - FocusedPanel::Thinking => { - self.thinking_scroll.jump_to_top(); - } - FocusedPanel::Files => { - self.file_tree_mut().jump_to_top(); - } - FocusedPanel::Code => { - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.jump_to_top(); - } - } - FocusedPanel::Input => {} - } - } - - /// Jump to bottom of focused panel - pub fn jump_to_bottom(&mut self) { - match self.focused_panel { - FocusedPanel::Chat => { - self.auto_scroll.jump_to_bottom(self.viewport_height); - self.update_new_message_alert_after_scroll(); - let rendered = self.get_rendered_lines(); - if rendered.is_empty() { - self.chat_cursor = (0, 0); - } else { - let last_index = rendered.len().saturating_sub(1); - let last_col = rendered - .last() - .map(|line| line.chars().count()) - .unwrap_or(0); - self.chat_cursor = (last_index, last_col); - } - } - FocusedPanel::Thinking => { - let viewport_height = self.thinking_viewport_height.max(1); - self.thinking_scroll.jump_to_bottom(viewport_height); - } - FocusedPanel::Files => { - self.file_tree_mut().jump_to_bottom(); - } - FocusedPanel::Code => { - let viewport_height = self.code_view_viewport_height().max(1); - if let Some(scroll) = self.code_view_scroll_mut() { - scroll.jump_to_bottom(viewport_height); - } - } - FocusedPanel::Input => {} - } - } - - pub async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> { - match event { - SessionEvent::StreamChunk { - message_id, - response, - } => { - self.controller_lock_async() - .await - .apply_stream_chunk(message_id, &response)?; - self.invalidate_message_cache(&message_id); - if let Some(active) = self.active_command.as_mut() { - active.record_response(message_id); - } - - // Update thinking content in real-time during streaming - self.update_thinking_from_last_message(); - self.notify_new_activity(); - - // Auto-scroll will handle this in the render loop - if response.is_final { - let recorded_snapshot = match response.usage.as_ref() { - Some(usage) => { - self.update_context_usage(usage); - let controller = self.controller_lock_async().await; - controller.record_usage_sample(usage).await - } - None => None, - }; - if let Some(snapshot) = recorded_snapshot { - self.usage_snapshot = Some(snapshot.clone()); - self.update_usage_toasts(&snapshot); - } else { - self.refresh_usage_summary().await?; - } - - self.streaming.remove(&message_id); - self.stream_tasks.remove(&message_id); - self.stop_loading_animation(); - - // Check if the completed stream has tool calls that need execution - let pending_tool_calls = { - let mut controller = self.controller_lock_async().await; - controller.check_streaming_tool_calls(message_id) - }; - if let Some(tool_calls) = pending_tool_calls { - // Trigger tool execution via event - let sender = self.session_tx.clone(); - let _ = sender.send(SessionEvent::ToolExecutionNeeded { - message_id, - tool_calls, - }); - } else { - self.status = "Ready".to_string(); - self.mark_active_command_succeeded(); - } - } - - self.refresh_attachment_gallery(); - } - SessionEvent::StreamError { - message_id, - message, - error, - } => { - self.stop_loading_animation(); - let handled = if let Some(err) = error.as_ref() { - self.handle_provider_error(err).await? - } else { - false - }; - if let Some(id) = message_id { - self.streaming.remove(&id); - self.stream_tasks.remove(&id); - self.invalidate_message_cache(&id); - } else { - self.streaming.clear(); - self.stream_tasks.clear(); - self.message_line_cache.clear(); - } - if handled { - let active_error = self.error.clone(); - self.mark_active_command_failed(active_error); - } else { - self.error = Some(message.clone()); - self.mark_active_command_failed(Some(message)); - } - } - SessionEvent::ToolExecutionNeeded { - message_id, - tool_calls, - } => { - // Store tool execution for async processing on next event loop iteration - self.pending_tool_execution = Some((message_id, tool_calls)); - } - SessionEvent::AgentUpdate { content } => { - // Update agent actions panel with latest ReAct iteration - self.set_agent_actions(content); - } - SessionEvent::AgentCompleted { answer } => { - // Agent finished, add final answer to conversation - let message_id = self.push_assistant_message(answer); - self.notify_new_activity(); - self.refresh_attachment_gallery(); - self.agent_running = false; - self.agent_mode = false; - self.agent_actions = None; - self.status = "Agent completed successfully".to_string(); - self.stop_loading_animation(); - if let Some(active) = self.active_command.as_mut() { - active.record_response(message_id); - } - self.update_thinking_from_last_message(); - self.mark_active_command_succeeded(); - } - SessionEvent::AgentFailed { error } => { - // Agent failed, show error - let message = format!("Agent failed: {}", error); - self.error = Some(message.clone()); - self.agent_running = false; - self.agent_actions = None; - self.stop_loading_animation(); - self.mark_active_command_failed(Some(message)); - } - SessionEvent::OAuthPoll { - server, - authorization, - } => { - let poll_result = { - let mut controller = self.controller_lock_async().await; - controller - .poll_oauth_device_flow(&server, &authorization) - .await - }; - match poll_result { - Ok(DevicePollState::Pending { retry_in }) => { - self.oauth_flows - .insert(server.clone(), authorization.clone()); - let server_name = server.clone(); - self.schedule_oauth_poll(server, authorization, retry_in); - self.status = format!("Waiting for OAuth approval for {server_name}..."); - } - Ok(DevicePollState::Complete(_token)) => { - self.oauth_flows.remove(&server); - self.push_toast( - ToastLevel::Success, - format!("OAuth authorization complete for {server}."), - ); - self.status = format!("OAuth authorization complete for {server}."); - if let Err(err) = self.refresh_resource_catalog().await { - self.push_toast( - ToastLevel::Error, - format!("Failed to refresh MCP resources: {err}"), - ); - } - if let Err(err) = self.refresh_mcp_slash_commands().await { - self.push_toast( - ToastLevel::Error, - format!("Failed to refresh MCP slash commands: {err}"), - ); - } - } - Err(err) => { - self.oauth_flows.remove(&server); - self.error = Some(format!("OAuth flow for '{server}' failed: {err}")); - self.push_toast( - ToastLevel::Error, - format!("OAuth failure for {server}: {err}"), - ); - } - }; - } - } - Ok(()) - } - - fn reset_status(&mut self) { - self.status = "Normal mode • Press F1 for help".to_string(); - self.error = None; - } - - async fn refresh_usage_summary(&mut self) -> Result<()> { - let snapshot_opt = { - let controller = self.controller_lock_async().await; - controller.current_usage_snapshot().await - }; - if let Some(snapshot) = snapshot_opt { - self.usage_snapshot = Some(snapshot.clone()); - self.update_usage_toasts(&snapshot); - } else { - self.usage_snapshot = None; - } - Ok(()) - } - - fn update_usage_toasts(&mut self, snapshot: &UsageSnapshot) { - for window in [UsageWindow::Hour, UsageWindow::Week] { - let key = (snapshot.provider.clone(), window); - let metrics = snapshot.window(window); - let quota = match metrics.quota_tokens { - Some(value) if value > 0 => value, - _ => { - self.usage_thresholds.remove(&key); - continue; - } - }; - - let previous = self - .usage_thresholds - .get(&key) - .copied() - .unwrap_or(UsageBand::Normal); - let current = metrics.band(); - - if current > previous { - if let Some(percent_ratio) = metrics.percent_of_quota() { - let percent_value = percent_ratio * 100.0; - let percent_text = Self::format_percent_value(percent_value.min(999.9)); - let quota_text = format_token_short(quota); - let used_text = format_token_short(metrics.total_tokens); - let provider_display = Self::provider_display_name(&snapshot.provider); - let window_label = Self::usage_window_label(window); - let message = format!( - "{} {} usage at {}% ({}/{})", - provider_display, window_label, percent_text, used_text, quota_text - ); - let level = if current == UsageBand::Critical { - ToastLevel::Error - } else { - ToastLevel::Warning - }; - self.push_toast_with_hint(level, message, ":limits"); - } - } else if current == UsageBand::Normal && previous != UsageBand::Normal { - self.usage_thresholds.insert(key.clone(), UsageBand::Normal); - } - - self.usage_thresholds.insert(key, current); - } - } - - async fn install_tool_preset(&mut self, preset: &str, prune: bool) -> Result { - let tier = PresetTier::from_str(preset).map_err(|err| anyhow!(err.to_string()))?; - let report = self.with_config_mut(|cfg| presets::apply_preset(cfg, tier, prune))?; - - self.with_config(|cfg| { - config::save_config(cfg) - .context("failed to persist configuration after installing preset") - })?; - { - let mut controller = self.controller_lock_async().await; - controller.reload_mcp_clients().await?; - } - - Ok(format!( - "Installed '{}' preset (added {}, updated {}, removed {}).", - tier.as_str(), - report.added.len(), - report.updated.len(), - report.removed.len() - )) - } - - async fn audit_tool_preset(&mut self, preset: Option<&str>) -> Result { - let tier = if let Some(name) = preset { - PresetTier::from_str(name).map_err(|err| anyhow!(err.to_string()))? - } else { - PresetTier::Full - }; - - let audit = self.with_config(|cfg| presets::audit_preset(cfg, tier)); - - let mut sections = Vec::new(); - if !audit.missing.is_empty() { - let names = audit - .missing - .iter() - .map(|connector| connector.name) - .collect::>() - .join(", "); - sections.push(format!("missing {names}")); - } - if !audit.mismatched.is_empty() { - let names = audit - .mismatched - .iter() - .map(|(expected, _)| expected.name) - .collect::>() - .join(", "); - sections.push(format!("mismatched {names}")); - } - if !audit.extra.is_empty() { - let names = audit - .extra - .iter() - .map(|server| server.name.as_str()) - .collect::>() - .join(", "); - sections.push(format!("extra {names}")); - } - - if sections.is_empty() { - Ok(format!("Preset '{}' audit passed.", tier.as_str())) - } else { - Ok(format!( - "Preset '{}' audit findings: {}", - tier.as_str(), - sections.join("; ") - )) - } - } - - async fn show_usage_limits(&mut self) -> Result<()> { - let snapshots = { - let controller = self.controller_lock_async().await; - controller.usage_overview().await - }; - if snapshots.is_empty() { - let message = "Usage: no data recorded yet.".to_string(); - self.status = message.clone(); - self.error = None; - self.push_toast(ToastLevel::Info, message); - return Ok(()); - } - - let mut parts = Vec::new(); - let mut current_snapshot: Option = None; - - for snapshot in snapshots.iter() { - if snapshot.provider == self.current_provider { - current_snapshot = Some(snapshot.clone()); - } - self.update_usage_toasts(snapshot); - - let provider_display = Self::provider_display_name(&snapshot.provider); - let hour = Self::summarize_usage_window("hour", snapshot.window(UsageWindow::Hour)); - let week = Self::summarize_usage_window("week", snapshot.window(UsageWindow::Week)); - parts.push(format!("{provider_display}: {hour}; {week}")); - } - - if let Some(snapshot) = current_snapshot { - self.usage_snapshot = Some(snapshot); - } - - let message = parts.join(" | "); - self.status = format!("Usage • {message}"); - self.error = None; - self.push_toast(ToastLevel::Info, message.clone()); - - Ok(()) - } - - fn summarize_usage_window(label: &str, metrics: &WindowMetrics) -> String { - let used = format_token_short(metrics.total_tokens); - if let Some(quota) = metrics.quota_tokens { - if quota == 0 { - return format!("{label} {used} tokens"); - } - let quota_text = format_token_short(quota); - let percent = metrics - .percent_of_quota() - .map(|ratio| ratio * 100.0) - .unwrap_or(0.0); - let percent_text = Self::format_percent_value(percent.min(999.9)); - format!("{label} {used}/{quota_text} ({percent_text}%)") - } else { - format!("{label} {used} tokens") - } - } - - fn usage_window_label(window: UsageWindow) -> &'static str { - match window { - UsageWindow::Hour => "hourly", - UsageWindow::Week => "weekly", - } - } - - fn format_percent_value(percent: f64) -> String { - if percent >= 10.0 || percent == 0.0 { - format!("{percent:.0}") - } else { - format!("{percent:.1}") - } - } - - async fn collect_models_from_all_providers( - &self, - ) -> ( - Vec, - Vec, - HashMap, - ) { - let provider_entries: Vec<(String, ProviderConfig)> = self.with_config(|config| { - config - .providers - .iter() - .map(|(name, cfg)| (name.clone(), cfg.clone())) - .collect() - }); - - let mut models = Vec::new(); - let mut errors = Vec::new(); - let mut scope_status_map: HashMap = HashMap::new(); - - let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .ok(); - let server_binary = workspace_root.as_ref().and_then(|root| { - let candidates = [ - "target/debug/owlen-mcp-llm-server", - "target/release/owlen-mcp-llm-server", - ]; - candidates - .iter() - .map(|rel| root.join(rel)) - .find(|p| p.exists()) - .map(|p| p.to_string_lossy().into_owned()) - }); - - for (name, provider_cfg) in provider_entries { - let provider_type = provider_cfg.provider_type.to_ascii_lowercase(); - if provider_type != "ollama" && provider_type != "ollama_cloud" { - continue; - } - - if !provider_cfg.enabled { - continue; - } - - let canonical_name = Self::canonical_provider_id(&name); - - // All providers communicate via MCP LLM server (Phase 10). - // Select provider by name via OWLEN_PROVIDER so per-provider settings apply. - let mut env_vars = HashMap::new(); - env_vars.insert("OWLEN_PROVIDER".to_string(), canonical_name.clone()); - - let client_result = if let Some(binary_path) = server_binary.as_ref() { - use owlen_core::config::McpServerConfig; - - let config = McpServerConfig { - name: format!("provider::{canonical_name}"), - command: binary_path.clone(), - args: Vec::new(), - transport: "stdio".to_string(), - env: env_vars.clone(), - oauth: None, - rpc_timeout_secs: None, - }; - RemoteMcpClient::new_with_config(&config).await - } else { - // Fallback to legacy discovery: temporarily set env vars while spawning. - Self::with_temp_env_vars(&env_vars, RemoteMcpClient::new).await - }; - - match client_result { - Ok(client) => { - let client: Arc = Arc::new(client); - match client.list_models().await { - Ok(mut provider_models) => { - for model in &mut provider_models { - model.provider = canonical_name.clone(); - } - let statuses = Self::extract_scope_status(&provider_models); - Self::accumulate_scope_errors(&mut errors, &canonical_name, &statuses); - scope_status_map.insert(canonical_name.clone(), statuses); - models.extend(provider_models); - } - Err(err) => { - scope_status_map - .insert(canonical_name.clone(), ProviderScopeStatus::default()); - errors.push(format!("{}: {}", name, err)) - } - } - } - Err(err) => { - scope_status_map.insert(canonical_name.clone(), ProviderScopeStatus::default()); - errors.push(format!("{}: {}", canonical_name, err)); - } - } - } - - // Sort models alphabetically by name for a predictable UI order - models.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - (models, errors, scope_status_map) - } - - fn scope_from_keyword(value: &str) -> ModelScope { - match value { - "local" => ModelScope::Local, - "cloud" => ModelScope::Cloud, - other => ModelScope::Other(other.to_string()), - } - } - - fn extract_scope_status(models: &[ModelInfo]) -> ProviderScopeStatus { - let mut statuses: ProviderScopeStatus = BTreeMap::new(); - - for model in models { - for capability in &model.capabilities { - if let Some(rest) = capability.strip_prefix("scope-status:") { - let mut parts = rest.split(':'); - let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); - let state_key = parts.next().unwrap_or_default().to_ascii_lowercase(); - - let scope = Self::scope_from_keyword(&scope_key); - let state = match state_key.as_str() { - "available" => ModelAvailabilityState::Available, - "unavailable" => ModelAvailabilityState::Unavailable, - _ => ModelAvailabilityState::Unknown, - }; - - let entry = statuses.entry(scope).or_default(); - if state > entry.state || entry.state == ModelAvailabilityState::Unknown { - entry.state = state; - } - } else if let Some(rest) = capability.strip_prefix("scope-status-message:") { - let mut parts = rest.split(':'); - let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); - let message = parts.collect::>().join(":"); - let scope = Self::scope_from_keyword(&scope_key); - let entry = statuses.entry(scope).or_default(); - if entry.message.is_none() && !message.trim().is_empty() { - entry.message = Some(message.trim().to_string()); - } - } else if let Some(rest) = capability.strip_prefix("scope-status-age:") { - let mut parts = rest.split(':'); - let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); - let value = parts.next().unwrap_or_default(); - if let Ok(age) = value.parse::() { - let scope = Self::scope_from_keyword(&scope_key); - let entry = statuses.entry(scope).or_default(); - entry.last_checked_secs = Some(age); - } - } else if let Some(rest) = capability.strip_prefix("scope-status-success-age:") { - let mut parts = rest.split(':'); - let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); - let value = parts.next().unwrap_or_default(); - if let Ok(age) = value.parse::() { - let scope = Self::scope_from_keyword(&scope_key); - let entry = statuses.entry(scope).or_default(); - entry.last_success_secs = Some(age); - } - } else if let Some(rest) = capability.strip_prefix("scope-status-stale:") { - let mut parts = rest.split(':'); - let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); - let value = parts.next().unwrap_or_default(); - let scope = Self::scope_from_keyword(&scope_key); - let entry = statuses.entry(scope).or_default(); - entry.is_stale = matches!(value, "1" | "true" | "True" | "TRUE"); - } - } - } - - statuses - } - - fn accumulate_scope_errors( - errors: &mut Vec, - provider: &str, - statuses: &ProviderScopeStatus, - ) { - for (scope, entry) in statuses { - if entry.state == ModelAvailabilityState::Unavailable { - let scope_name = Self::scope_display_name(scope); - if let Some(summary) = Self::scope_status_summary(entry) { - errors.push(format!("{provider}: {scope_name} {summary}")); - } else { - errors.push(format!("{provider}: {scope_name} unavailable")); - } - } else if entry.state == ModelAvailabilityState::Available && entry.is_stale { - let scope_name = Self::scope_display_name(scope); - let summary = Self::scope_status_summary(entry) - .unwrap_or_else(|| "using cached results".to_string()); - errors.push(format!("{provider}: {scope_name} degraded ({summary})")); - } - } - } - - pub(crate) fn model_scope_from_capabilities(model: &ModelInfo) -> ModelScope { - for capability in &model.capabilities { - if let Some(tag) = capability.strip_prefix("scope:") { - return match tag { - "local" => ModelScope::Local, - "cloud" => ModelScope::Cloud, - other => ModelScope::Other(other.to_string()), - }; - } - } - - ModelScope::Other("unknown".to_string()) - } - - pub(crate) fn scope_icon(scope: &ModelScope) -> &'static str { - match scope { - ModelScope::Local => "🖥️", - ModelScope::Cloud => "☁", - ModelScope::Other(_) => "◇", - } - } - - pub(crate) fn scope_display_name(scope: &ModelScope) -> String { - match scope { - ModelScope::Local => "Local".to_string(), - ModelScope::Cloud => "Cloud".to_string(), - ModelScope::Other(other) => capitalize_first(other), - } - } - - fn format_duration_short(seconds: u64) -> String { - const MINUTE: u64 = 60; - const HOUR: u64 = 60 * MINUTE; - const DAY: u64 = 24 * HOUR; - - if seconds < MINUTE { - format!("{seconds}s") - } else if seconds < HOUR { - format!("{}m", seconds / MINUTE) - } else if seconds < DAY { - let hours = seconds / HOUR; - let minutes = (seconds % HOUR) / MINUTE; - if minutes == 0 { - format!("{hours}h") - } else { - format!("{hours}h{minutes}m") - } - } else { - format!("{}d", seconds / DAY) - } - } - - fn scope_status_summary(status: &ScopeStatusEntry) -> Option { - let mut segments: Vec = Vec::new(); - - if let Some(message) = status.message.as_ref() { - if !message.is_empty() { - segments.push(message.clone()); - } - } else if status.state == ModelAvailabilityState::Unavailable { - segments.push("Unavailable".to_string()); - } else if status.state == ModelAvailabilityState::Available && status.is_stale { - segments.push("Using cached results".to_string()); - } - - if let Some(age) = status.last_checked_secs { - segments.push(format!("checked {} ago", Self::format_duration_short(age))); - } - - if let Some(success_age) = status.last_success_secs { - if status.state == ModelAvailabilityState::Unavailable { - segments.push(format!( - "last ok {} ago", - Self::format_duration_short(success_age) - )); - } else if status.state == ModelAvailabilityState::Available && status.is_stale { - segments.push(format!( - "last refresh {} ago", - Self::format_duration_short(success_age) - )); - } - } - - if segments.is_empty() { - None - } else { - Some(segments.join(" · ")) - } - } - - fn scope_header_label( - scope: &ModelScope, - status: &ScopeStatusEntry, - filter: FilterMode, - ) -> String { - let icon = Self::scope_icon(scope); - let scope_name = Self::scope_display_name(scope); - let mut label = format!("{icon} {scope_name}"); - - match status.state { - ModelAvailabilityState::Available => { - label.push_str(" · ✓"); - if status.is_stale { - label.push_str(" · ⚠"); - } - } - ModelAvailabilityState::Unavailable => { - if status.last_success_secs.is_some() { - label.push_str(" · ⚠"); - } else { - label.push_str(" · ✗"); - } - } - ModelAvailabilityState::Unknown => label.push_str(" · ⚙"), - } - - if let Some(age) = status.last_checked_secs { - label.push_str(&format!(" · {}", Self::format_duration_short(age))); - } - - if matches!(filter, FilterMode::Available) { - label.push_str(" · available only"); - } - - label - } - - fn deduplicate_models_for_scope<'a>( - entries: Vec<(usize, &'a ModelInfo)>, - provider_lower: &str, - scope: &ModelScope, - ) -> Vec<(usize, &'a ModelInfo)> { - let mut best_by_canonical: HashMap = HashMap::new(); - - for (idx, model) in entries { - let canonical = model.id.to_string(); - let is_cloud_id = model.id.ends_with("-cloud"); - let priority = if matches!( - provider_lower, - "ollama" | "ollama_local" | "ollama-cloud" | "ollama_cloud" - ) { - match scope { - ModelScope::Local => { - if is_cloud_id { - 1 - } else { - 2 - } - } - ModelScope::Cloud => { - if is_cloud_id { - 2 - } else { - 1 - } - } - ModelScope::Other(_) => 1, - } - } else { - 1 - }; - - best_by_canonical - .entry(canonical) - .and_modify(|entry| { - if priority > entry.0 || (priority == entry.0 && model.id < entry.1.1.id) { - *entry = (priority, (idx, model)); - } - }) - .or_insert((priority, (idx, model))); - } - - let mut matches: Vec<(usize, &'a ModelInfo)> = best_by_canonical - .into_values() - .map(|entry| entry.1) - .collect(); - - matches.sort_by(|(_, a), (_, b)| a.id.cmp(&b.id)); - matches - } - - fn recompute_available_providers(&mut self) { - let mut providers: BTreeSet = self - .controller_lock() - .config() - .providers - .iter() - .filter(|(_, cfg)| cfg.enabled) - .map(|(name, _)| Self::canonical_provider_id(name)) - .collect(); - - providers.extend(self.models.iter().map(|m| m.provider.clone())); - - if providers.is_empty() { - providers.insert(self.selected_provider.clone()); - } - - if providers.is_empty() { - providers.insert("ollama_local".to_string()); - } - - self.available_providers = providers.into_iter().collect(); - } - - fn canonical_provider_id(provider: &str) -> String { - let normalized = provider.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return "ollama_local".to_string(); - } - match normalized.replace('-', "_").as_str() { - "ollama" => "ollama_local".to_string(), - "ollama_local" => "ollama_local".to_string(), - "ollama_cloud" => "ollama_cloud".to_string(), - other => other.to_string(), - } - } - - fn with_temp_env_vars(env_vars: &HashMap, action: F) -> T - where - F: FnOnce() -> T, - { - let backups: Vec<(String, Option)> = env_vars - .keys() - .map(|key| (key.clone(), std::env::var(key).ok())) - .collect(); - - for (key, value) in env_vars { - // Safety: environment mutations are scoped to this synchronous call and restored - // immediately afterwards, so no other threads observe inconsistent state. - unsafe { - std::env::set_var(key, value); - } - } - - let result = action(); - - for (key, original) in backups { - unsafe { - if let Some(value) = original { - std::env::set_var(&key, value); - } else { - std::env::remove_var(&key); - } - } - } - - result - } - - fn rebuild_annotated_models(&mut self) { - let mut annotated = Vec::with_capacity(self.models.len()); - for model in &self.models { - let provider_id = model.provider.clone(); - let scope = Self::model_scope_from_capabilities(model); - let scope_state = self.provider_scope_state(provider_id.as_str(), &scope); - let provider_status = Self::provider_status_from_state(scope_state); - let provider_type = Self::infer_provider_type(&provider_id, &scope); - - let mut provider_metadata = ProviderMetadata::new( - provider_id.clone(), - Self::provider_display_name(&provider_id), - provider_type, - matches!(provider_type, ProviderType::Cloud), - ); - provider_metadata.metadata.insert( - "scope".to_string(), - Value::String(Self::scope_display_name(&scope)), - ); - provider_metadata.metadata.insert( - "provider_tag".to_string(), - Value::String(Self::provider_tag(&provider_id)), - ); - - let mut model_metadata = HashMap::new(); - model_metadata.insert( - "display_name".to_string(), - Value::String(Self::display_name_for_model(model)), - ); - if let Some(ctx) = model.context_window { - model_metadata.insert("context_window".to_string(), Value::from(ctx)); - } - model_metadata.insert( - "provider_tag".to_string(), - Value::String(Self::provider_tag(&provider_id)), - ); - - let provider_model = ProviderModelInfo { - name: model.id.clone(), - size_bytes: None, - capabilities: model.capabilities.clone(), - description: model.description.clone(), - provider: provider_metadata, - metadata: model_metadata, - }; - - annotated.push(AnnotatedModelInfo { - provider_id, - provider_status, - model: provider_model, - }); - } - - self.annotated_models = annotated; - } - - fn rebuild_model_selector_items(&mut self) { - let mut items = Vec::new(); - self.model_search_hits.clear(); - self.provider_search_hits.clear(); - self.visible_model_count = 0; - - if self.available_providers.is_empty() { - items.push(ModelSelectorItem::header( - "ollama_local", - false, - ProviderStatus::RequiresSetup, - ProviderType::Local, - )); - self.model_selector_items = items; - return; - } - - let search_query = self.model_search_query.trim().to_string(); - let search_active = !search_query.is_empty(); - let force_expand = search_active; - let expanded = self.expanded_provider.clone(); - - for provider in &self.available_providers { - let provider_lower = provider.to_ascii_lowercase(); - let provider_display = Self::provider_display_name(provider); - let provider_status = self.provider_overall_status(provider); - let provider_type = self.provider_type_for(provider); - let provider_highlight = if search_active { - search_candidate(provider_display.as_str(), &search_query).map(|(_, mask)| mask) - } else { - None - }; - if let Some(mask) = provider_highlight.clone() { - self.provider_search_hits.insert(provider.clone(), mask); - } - - let is_expanded = - force_expand || expanded.as_ref().map(|p| p == provider).unwrap_or(false); - - let mut provider_block = Vec::new(); - provider_block.push(ModelSelectorItem::header( - provider.clone(), - is_expanded, - provider_status, - provider_type, - )); - - if !is_expanded { - items.extend(provider_block); - continue; - } - - let status_map = self.provider_scope_status.get(provider); - - let mut scoped: BTreeMap> = BTreeMap::new(); - for (idx, model) in self.models.iter().enumerate() { - if &model.provider == provider { - let scope = Self::model_scope_from_capabilities(model); - scoped.entry(scope).or_default().push((idx, model)); - } - } - - let mut scopes_to_render: BTreeSet = BTreeSet::new(); - scopes_to_render.extend(scoped.keys().cloned()); - if let Some(statuses) = status_map { - scopes_to_render.extend(statuses.keys().cloned()); - } - - let mut rendered_scope = false; - let mut rendered_body = false; - let mut provider_has_models = false; - - for scope in scopes_to_render { - if !self.filter_allows_scope(&scope) { - continue; - } - - let entries = scoped.get(&scope).cloned().unwrap_or_default(); - let deduped = Self::deduplicate_models_for_scope(entries, &provider_lower, &scope); - - let status_entry = status_map - .and_then(|map| map.get(&scope)) - .cloned() - .unwrap_or_default(); - - let mut filtered: Vec<(usize, &ModelInfo)> = Vec::new(); - for (idx, model) in deduped { - let search_info = if search_active { - self.evaluate_model_search(provider, model, &search_query) - } else { - None - }; - - if let Some(info) = search_info { - self.model_search_hits.insert(idx, info); - filtered.push((idx, model)); - } else if !search_active { - filtered.push((idx, model)); - } - } - - if search_active && filtered.is_empty() { - continue; - } - - rendered_scope = true; - - let label = Self::scope_header_label(&scope, &status_entry, self.model_filter_mode); - provider_block.push(ModelSelectorItem::scope( - provider.clone(), - label, - scope.clone(), - status_entry.state, - )); - - if (status_entry.state != ModelAvailabilityState::Available - || status_entry.is_stale - || status_entry.message.is_some()) - && let Some(summary) = Self::scope_status_summary(&status_entry) - { - provider_block.push(ModelSelectorItem::empty( - provider.clone(), - Some(summary), - Some(status_entry.state), - )); - rendered_body = true; - } - - let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry); - - if filtered.is_empty() { - if !scope_allowed { - if let Some(msg) = self.scope_filter_message(&scope, &status_entry) { - provider_block.push(ModelSelectorItem::empty( - provider.clone(), - Some(msg), - Some(status_entry.state), - )); - rendered_body = true; - } - } else if !search_active { - let message = match status_entry.state { - ModelAvailabilityState::Unavailable => { - format!("{} unavailable", Self::scope_display_name(&scope)) - } - ModelAvailabilityState::Available => { - format!("No {} models found", Self::scope_display_name(&scope)) - } - ModelAvailabilityState::Unknown => "No models configured".to_string(), - }; - provider_block.push(ModelSelectorItem::empty( - provider.clone(), - Some(message), - Some(status_entry.state), - )); - rendered_body = true; - } - continue; - } - - if !scope_allowed { - if let Some(msg) = self.scope_filter_message(&scope, &status_entry) { - provider_block.push(ModelSelectorItem::empty( - provider.clone(), - Some(msg), - Some(status_entry.state), - )); - rendered_body = true; - } - continue; - } - - rendered_body = true; - provider_has_models = true; - - for (idx, _) in filtered { - provider_block.push(ModelSelectorItem::model(provider.clone(), idx)); - } - } - - if !provider_has_models && search_active && provider_highlight.is_some() { - provider_block.push(ModelSelectorItem::empty( - provider.clone(), - Some(format!( - "Provider matches '{}' but no models found", - search_query - )), - None, - )); - rendered_body = true; - } - - if !rendered_scope && !rendered_body { - if !search_active { - provider_block.push(ModelSelectorItem::empty(provider.clone(), None, None)); - } else if provider_highlight.is_some() { - provider_block.push(ModelSelectorItem::empty( - provider.clone(), - Some(format!("No models matching '{}'", search_query)), - None, - )); - } else { - continue; - } - } - - items.extend(provider_block); - } - - if items.is_empty() { - items.push(ModelSelectorItem::empty( - "providers", - Some(if search_active { - format!("No models matching '{}'", search_query) - } else { - "No providers configured".to_string() - }), - None, - )); - } - - self.visible_model_count = items - .iter() - .filter(|item| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) - .count(); - - self.model_selector_items = items; - self.ensure_valid_model_selection(); - - if search_active { - let current_is_model = self - .current_model_selector_item() - .map(|item| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) - .unwrap_or(false); - - if !current_is_model - && let Some((idx, _)) = self - .model_selector_items - .iter() - .enumerate() - .find(|(_, item)| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) - { - self.set_selected_model_item(idx); - } - } - } - - fn evaluate_model_search( - &self, - provider: &str, - model: &ModelInfo, - query: &str, - ) -> Option { - let mut info = ModelSearchInfo::default(); - let mut best: Option<(usize, usize)> = None; - - let mut consider = |candidate: Option<&str>, target: &mut Option| { - if let Some(text) = candidate - && let Some((score, mask)) = search_candidate(text, query) - { - let replace = best.is_none_or(|current| score < current); - if replace { - best = Some(score); - } - *target = Some(mask); - } - }; - - let display_name = Self::display_name_for_model(model); - consider(Some(display_name.as_str()), &mut info.name); - consider(Some(model.id.as_str()), &mut info.id); - let provider_display = Self::provider_display_name(provider); - consider(Some(provider_display.as_str()), &mut info.provider); - if let Some(desc) = model.description.as_deref() { - consider(Some(desc), &mut info.description); - } - - if let Some(score) = best { - info.score = score; - Some(info) - } else { - None - } - } - - fn provider_scope_state(&self, provider: &str, scope: &ModelScope) -> ModelAvailabilityState { - self.provider_scope_status - .get(provider) - .and_then(|map| map.get(scope)) - .map(|entry| entry.state) - .unwrap_or(ModelAvailabilityState::Unknown) - } - - fn provider_overall_status(&self, provider: &str) -> ProviderStatus { - if let Some(status_map) = self.provider_scope_status.get(provider) { - let mut saw_unknown = false; - for entry in status_map.values() { - match entry.state { - ModelAvailabilityState::Unavailable => return ProviderStatus::Unavailable, - ModelAvailabilityState::Unknown => saw_unknown = true, - ModelAvailabilityState::Available => { - if entry.is_stale { - saw_unknown = true; - } - } - } - } - if saw_unknown { - ProviderStatus::RequiresSetup - } else { - ProviderStatus::Available - } - } else { - self.annotated_models - .iter() - .find(|m| m.provider_id == provider) - .map(|m| m.provider_status) - .unwrap_or(ProviderStatus::RequiresSetup) - } - } - - fn provider_type_for(&self, provider: &str) -> ProviderType { - self.annotated_models - .iter() - .find(|m| m.provider_id == provider) - .map(|m| m.model.provider.provider_type) - .unwrap_or_else(|| { - if provider.to_ascii_lowercase().contains("cloud") { - ProviderType::Cloud - } else { - ProviderType::Local - } - }) - } - - fn filter_allows_scope(&self, scope: &ModelScope) -> bool { - match self.model_filter_mode { - FilterMode::All => true, - FilterMode::LocalOnly => matches!(scope, ModelScope::Local), - FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud), - FilterMode::Available => true, - } - } - - fn filter_scope_allows_models(&self, scope: &ModelScope, status: &ScopeStatusEntry) -> bool { - match self.model_filter_mode { - FilterMode::Available => { - status.state == ModelAvailabilityState::Available && !status.is_stale - } - FilterMode::LocalOnly => matches!(scope, ModelScope::Local), - FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud), - FilterMode::All => true, - } - } - - fn scope_filter_message( - &self, - scope: &ModelScope, - status: &ScopeStatusEntry, - ) -> Option { - match self.model_filter_mode { - FilterMode::Available => { - if status.state == ModelAvailabilityState::Available && !status.is_stale { - return None; - } - Self::scope_status_summary(status).or_else(|| match status.state { - ModelAvailabilityState::Unavailable => { - Some(format!("{} unavailable", Self::scope_display_name(scope))) - } - ModelAvailabilityState::Unknown => Some(format!( - "{} setup required", - Self::scope_display_name(scope) - )), - ModelAvailabilityState::Available => Some(format!( - "{} cached results", - Self::scope_display_name(scope) - )), - }) - } - FilterMode::LocalOnly | FilterMode::CloudOnly => { - if status.state == ModelAvailabilityState::Unavailable { - Self::scope_status_summary(status).or_else(|| { - Some(format!("{} unavailable", Self::scope_display_name(scope))) - }) - } else { - None - } - } - FilterMode::All => None, - } - } - - pub(crate) fn provider_display_name(provider: &str) -> String { - if provider.trim().is_empty() { - return "Provider".to_string(); - } - let normalized = provider.replace(['_', '-'], " "); - capitalize_first(normalized.as_str()) - } - - fn provider_tag(provider: &str) -> String { - match provider.trim().to_ascii_lowercase().as_str() { - "ollama" | "ollama_local" => "ollama".to_string(), - "ollama-cloud" | "ollama_cloud" => "ollama-cloud".to_string(), - other => other.to_string(), - } - } - - fn infer_provider_type(provider: &str, scope: &ModelScope) -> ProviderType { - match scope { - ModelScope::Local => ProviderType::Local, - ModelScope::Cloud => ProviderType::Cloud, - ModelScope::Other(_) => { - if provider.to_ascii_lowercase().contains("cloud") { - ProviderType::Cloud - } else { - ProviderType::Local - } - } - } - } - - fn provider_status_from_state(state: ModelAvailabilityState) -> ProviderStatus { - match state { - ModelAvailabilityState::Available => ProviderStatus::Available, - ModelAvailabilityState::Unavailable => ProviderStatus::Unavailable, - ModelAvailabilityState::Unknown => ProviderStatus::RequiresSetup, - } - } - - fn first_model_item_index(&self) -> Option { - self.model_selector_items - .iter() - .enumerate() - .find(|(_, item)| item.is_model()) - .map(|(idx, _)| idx) - } - - fn index_of_header(&self, provider: &str) -> Option { - self.model_selector_items - .iter() - .enumerate() - .find(|(_, item)| item.provider_if_header() == Some(provider)) - .map(|(idx, _)| idx) - } - - fn index_of_first_model_for_provider(&self, provider: &str) -> Option { - self.model_selector_items - .iter() - .enumerate() - .find(|(_, item)| { - matches!( - item.kind(), - ModelSelectorItemKind::Model { provider: p, .. } if p == provider - ) - }) - .map(|(idx, _)| idx) - } - - fn index_of_model_id(&self, model_id: &str) -> Option { - self.model_selector_items - .iter() - .enumerate() - .find(|(_, item)| { - item.model_index() - .and_then(|idx| self.models.get(idx)) - .map(|model| model.id == model_id) - .unwrap_or(false) - }) - .map(|(idx, _)| idx) - } - - fn selected_model_info(&self) -> Option<&ModelInfo> { - self.selected_model_item - .and_then(|idx| self.model_selector_items.get(idx)) - .and_then(|item| item.model_index()) - .and_then(|model_index| self.models.get(model_index)) - } - - fn current_model_selector_item(&self) -> Option<&ModelSelectorItem> { - self.selected_model_item - .and_then(|idx| self.model_selector_items.get(idx)) - } - - fn set_selected_model_item(&mut self, index: usize) { - if self.model_selector_items.is_empty() { - self.selected_model_item = None; - return; - } - - let clamped = index.min(self.model_selector_items.len().saturating_sub(1)); - self.selected_model_item = Some(clamped); - - if let Some(item) = self.model_selector_items.get(clamped) { - match item.kind() { - ModelSelectorItemKind::Header { provider, .. } - | ModelSelectorItemKind::Scope { provider, .. } - | ModelSelectorItemKind::Model { provider, .. } - | ModelSelectorItemKind::Empty { provider, .. } => { - self.selected_provider = provider.clone(); - self.update_selected_provider_index(); - } - } - } - } - - fn focus_first_model_in_scope(&mut self, scope: &ModelScope) -> bool { - if self.model_selector_items.is_empty() { - return false; - } - - let scope_index = self - .model_selector_items - .iter() - .enumerate() - .find(|(_, item)| matches!(item.kind(), ModelSelectorItemKind::Scope { scope: s, .. } if s == scope)) - .map(|(idx, _)| idx); - - let Some(scope_idx) = scope_index else { - return false; - }; - - self.set_selected_model_item(scope_idx); - - let len = self.model_selector_items.len(); - let mut cursor = scope_idx + 1; - while cursor < len { - match self.model_selector_items[cursor].kind() { - ModelSelectorItemKind::Model { .. } => { - self.set_selected_model_item(cursor); - return true; - } - ModelSelectorItemKind::Scope { .. } | ModelSelectorItemKind::Header { .. } => break, - _ => {} - } - cursor += 1; - } - - true - } - - fn focus_first_available_model(&mut self) -> bool { - if self.model_selector_items.is_empty() { - return false; - } - - if let Some(idx) = self.first_model_item_index() { - self.set_selected_model_item(idx); - true - } else { - false - } - } - - fn ensure_valid_model_selection(&mut self) { - if self.model_selector_items.is_empty() { - self.selected_model_item = None; - return; - } - - let needs_reset = self - .selected_model_item - .map(|idx| idx >= self.model_selector_items.len()) - .unwrap_or(true); - - if needs_reset { - self.set_selected_model_item(0); - } else if let Some(idx) = self.selected_model_item { - self.set_selected_model_item(idx); - } - } - - fn move_model_selection(&mut self, direction: i32) { - if self.model_selector_items.is_empty() { - self.selected_model_item = None; - return; - } - - let len = self.model_selector_items.len() as isize; - let mut idx = self.selected_model_item.unwrap_or(0) as isize + direction as isize; - - if idx < 0 { - idx = 0; - } else if idx >= len { - idx = len - 1; - } - - self.set_selected_model_item(idx as usize); - } - - fn update_selected_provider_index(&mut self) { - if let Some(idx) = self - .available_providers - .iter() - .position(|p| p == &self.selected_provider) - { - self.selected_provider_index = idx; - } else if !self.available_providers.is_empty() { - self.selected_provider_index = 0; - self.selected_provider = self.available_providers[0].clone(); - } else { - self.selected_provider_index = 0; - } - } - - fn expand_provider(&mut self, provider: &str, focus_first_model: bool) { - let provider_owned = provider.to_string(); - let needs_rebuild = self.expanded_provider.as_deref() != Some(provider); - self.selected_provider = provider_owned.clone(); - self.expanded_provider = Some(provider_owned.clone()); - if needs_rebuild { - self.rebuild_model_selector_items(); - } - self.ensure_valid_model_selection(); - - if focus_first_model { - if let Some(idx) = self.index_of_first_model_for_provider(&provider_owned) { - self.set_selected_model_item(idx); - } else if let Some(idx) = self.index_of_header(&provider_owned) { - self.set_selected_model_item(idx); - } - } else if let Some(idx) = self.index_of_header(&provider_owned) { - self.set_selected_model_item(idx); - } - } - - fn collapse_provider(&mut self, provider: &str) { - if self.expanded_provider.as_deref() == Some(provider) { - self.expanded_provider = None; - self.rebuild_model_selector_items(); - if let Some(idx) = self.index_of_header(provider) { - self.set_selected_model_item(idx); - } - } - } - - async fn switch_to_provider(&mut self, provider_name: &str) -> Result<()> { - let canonical_name = Self::canonical_provider_id(provider_name); - if Self::canonical_provider_id(&self.current_provider) == canonical_name { - return Ok(()); - } - - use owlen_core::config::McpServerConfig; - use std::collections::HashMap; - - if self.with_config(|cfg| cfg.provider(&canonical_name).is_none()) { - self.with_config_mut(|cfg| { - config::ensure_provider_config(cfg, &canonical_name); - }); - } - - let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .ok(); - let server_binary = workspace_root.as_ref().and_then(|root| { - [ - "target/debug/owlen-mcp-llm-server", - "target/release/owlen-mcp-llm-server", - ] - .iter() - .map(|rel| root.join(rel)) - .find(|p| p.exists()) - }); - - let mut env_vars = HashMap::new(); - env_vars.insert("OWLEN_PROVIDER".to_string(), canonical_name.clone()); - - let provider: Arc = if let Some(path) = server_binary { - let config = McpServerConfig { - name: canonical_name.clone(), - command: path.to_string_lossy().into_owned(), - args: Vec::new(), - transport: "stdio".to_string(), - env: env_vars, - oauth: None, - rpc_timeout_secs: None, - }; - Arc::new(RemoteMcpClient::new_with_config(&config).await?) - } else { - Arc::new(Self::with_temp_env_vars(&env_vars, RemoteMcpClient::new).await?) - }; - - { - let mut controller = self.controller_lock_async().await; - controller.switch_provider(provider).await?; - } - self.current_provider = canonical_name; - self.model_details_cache.clear(); - self.model_info_panel.clear(); - self.set_model_info_visible(false); - self.update_command_palette_catalog(); - Ok(()) - } - - async fn populate_model_details_cache_from_session(&mut self) { - self.model_details_cache.clear(); - - let mut populated = false; - let detail_fetch = { - let controller = self.controller_lock_async().await; - controller.all_model_details(false).await - }; - if let Ok(details) = detail_fetch { - for info in details { - self.model_details_cache.insert(info.name.clone(), info); - } - populated = !self.model_details_cache.is_empty(); - } - - if !populated { - let cached = { - let controller = self.controller_lock_async().await; - controller.cached_model_details().await - }; - for info in cached { - self.model_details_cache.insert(info.name.clone(), info); - } - } - } - - async fn refresh_models(&mut self) -> Result<()> { - let (config_model_name, config_model_provider) = self.with_config(|config| { - ( - config.general.default_model.clone(), - config.general.default_provider.clone(), - ) - }); - - let (all_models, errors, scope_status) = self.collect_models_from_all_providers().await; - - if all_models.is_empty() { - self.error = if errors.is_empty() { - Some("No models available".to_string()) - } else { - Some(errors.join("; ")) - }; - self.models.clear(); - self.provider_scope_status.clear(); - self.model_details_cache.clear(); - self.model_info_panel.clear(); - self.set_model_info_visible(false); - self.recompute_available_providers(); - if self.available_providers.is_empty() { - self.available_providers.push("ollama_local".to_string()); - } - self.rebuild_model_selector_items(); - self.selected_model_item = None; - self.status = "No models available".to_string(); - self.update_selected_provider_index(); - self.update_command_palette_catalog(); - return Ok(()); - } - - self.models = all_models; - self.provider_scope_status = scope_status; - self.rebuild_annotated_models(); - self.model_info_panel.clear(); - self.set_model_info_visible(false); - self.populate_model_details_cache_from_session().await; - - self.recompute_available_providers(); - - if self.available_providers.is_empty() { - self.available_providers.push("ollama_local".to_string()); - } - - if !config_model_provider.is_empty() { - self.selected_provider = config_model_provider.clone(); - } else { - self.selected_provider = self.available_providers[0].clone(); - } - - self.expanded_provider = Some(self.selected_provider.clone()); - self.update_selected_provider_index(); - // Ensure the default model is set after refreshing models (async) - { - let mut controller = self.controller_lock_async().await; - controller.ensure_default_model(&self.models).await; - } - self.sync_selected_model_index().await; - - let (current_model_name, current_model_provider) = { - let controller = self.controller_lock_async().await; - ( - controller.selected_model().to_string(), - controller.config().general.default_provider.clone(), - ) - }; - - if config_model_name.as_deref() != Some(¤t_model_name) - || config_model_provider != current_model_provider - { - if let Err(err) = self.with_config(config::save_config) { - self.error = Some(format!("Failed to save config: {err}")); - } else { - self.error = None; - } - } - - if !errors.is_empty() { - self.error = Some(errors.join("; ")); - } else { - self.error = None; - } - - self.status = format!( - "Loaded {} models across {} provider(s)", - self.models.len(), - self.available_providers.len() - ); - self.rebuild_model_selector_items(); - - self.update_command_palette_catalog(); - - Ok(()) - } - - async fn apply_model_selection(&mut self, model: ModelInfo) -> Result<()> { - let model_id = model.id.clone(); - let model_label = Self::display_name_for_model(&model); - - if let Err(err) = self.switch_to_provider(&model.provider).await { - self.error = Some(format!("Failed to switch provider: {}", err)); - self.status = "Provider switch failed".to_string(); - return Err(err); - } - - self.selected_provider = model.provider.clone(); - self.update_selected_provider_index(); - - let save_result = { - let mut controller = self.controller_lock_async().await; - controller.set_model(model_id.clone()).await; - let mut config = controller.config_mut(); - config.general.default_model = Some(model_id.clone()); - config.general.default_provider = self.selected_provider.clone(); - config::save_config(&config) - }; - self.status = format!( - "Using model: {} (provider: {})", - model_label, self.selected_provider - ); - match save_result { - Ok(_) => self.error = None, - Err(err) => { - self.error = Some(format!("Failed to save config: {}", err)); - } - } - self.set_input_mode(InputMode::Normal); - self.set_model_info_visible(false); - Ok(()) - } - - async fn apply_provider_mode(&mut self, provider: &str, mode: &str) -> Result<()> { - let normalized = match mode.trim().to_ascii_lowercase().as_str() { - "local" | "cloud" | "auto" => mode.trim().to_ascii_lowercase(), - other => { - return Err(anyhow!( - "Unknown provider mode '{other}'. Expected local, cloud, or auto" - )); - } - }; - - self.with_config_mut(|cfg| { - config::ensure_provider_config(cfg, provider); - }); - - let ensure_result = self.with_config_mut(|cfg| -> Result<()> { - if let Some(entry) = cfg.providers.get_mut(provider) { - entry.extra.insert( - OLLAMA_MODE_KEY.to_string(), - serde_json::Value::String(normalized.clone()), - ); - Ok(()) - } else { - Err(anyhow!("Provider '{provider}' is not configured")) - } - }); - ensure_result?; - - let save_result = self.with_config(config::save_config); - if let Err(err) = save_result { - self.error = Some(format!("Failed to save provider mode: {err}")); - return Err(err); - } - - if provider.eq_ignore_ascii_case(&self.selected_provider) - && let Err(err) = self.refresh_models().await - { - self.error = Some(format!( - "Provider mode updated but refreshing models failed: {}", - err - )); - return Err(err); - } - - self.error = None; - self.status = format!( - "Provider {} mode set to {}", - provider, - normalized.to_ascii_uppercase() - ); - Ok(()) - } - - async fn handle_cloud_command(&mut self, args: &[&str]) -> Result<()> { - if args.is_empty() { - return Err(anyhow!( - "Usage: :cloud [options]" - )); - } - - match args[0] { - "setup" => self.cloud_setup(&args[1..]).await, - "status" | "models" | "logout" => Err(anyhow!( - ":cloud {} is not implemented in the TUI. Run `owlen cloud {}` from the shell.", - args[0], - args[0] - )), - other => Err(anyhow!("Unknown :cloud subcommand: {other}")), - } - } - - async fn cloud_setup(&mut self, args: &[&str]) -> Result<()> { - let options = CloudSetupOptions::parse(args)?; - let mut stored_securely = false; - - let (existing_plain_api_key, normalized_endpoint, encryption_enabled, base_was_overridden) = - self.with_config_mut(|cfg| -> Result<(Option, String, bool, bool)> { - config::ensure_provider_config(cfg, &options.provider); - let entry = cfg - .providers - .get_mut(&options.provider) - .ok_or_else(|| anyhow!("Provider '{}' is not configured", options.provider))?; - - let existing = entry.api_key.clone(); - entry.enabled = true; - entry.provider_type = "ollama_cloud".to_string(); - let should_update_env = match entry.api_key_env.as_deref() { - None => true, - Some(value) => { - value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) - || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) - } - }; - if should_update_env { - entry.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); - } - let requested = options - .endpoint - .clone() - .unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()); - let normalized_endpoint_local = normalize_cloud_endpoint(&requested); - entry.extra.insert( - OLLAMA_CLOUD_ENDPOINT_KEY.to_string(), - Value::String(normalized_endpoint_local.clone()), - ); - let should_override = options.force_cloud_base_url - || entry - .base_url - .as_ref() - .map(|value| value.trim().is_empty()) - .unwrap_or(true); - let mut base_overridden_local = false; - if should_override { - entry.base_url = Some(normalized_endpoint_local.clone()); - base_overridden_local = true; - } - - let encryption_enabled = cfg.privacy.encrypt_local_data; - Ok(( - existing, - normalized_endpoint_local, - encryption_enabled, - base_overridden_local, - )) - })?; - let base_overridden = base_was_overridden; - - let credential_manager = { - let controller = self.controller_lock_async().await; - controller.credential_manager() - }; - - let mut resolved_api_key = options - .api_key - .clone() - .filter(|value| !value.trim().is_empty()); - if resolved_api_key.is_none() - && let Some(existing) = existing_plain_api_key.as_ref() - && !existing.trim().is_empty() - { - resolved_api_key = Some(existing.clone()); - } - - if resolved_api_key.is_none() && credential_manager.is_some() - && let Some(manager) = credential_manager.clone() - && let Some(credentials) = manager - .get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID) - .await - .with_context(|| "Failed to load stored Ollama Cloud credentials")? - && !credentials.api_key.trim().is_empty() - { - resolved_api_key = Some(credentials.api_key); - } - - if resolved_api_key.is_none() - && let Ok(env_key) = std::env::var("OLLAMA_API_KEY") - && !env_key.trim().is_empty() - { - resolved_api_key = Some(env_key); - } - - if resolved_api_key.is_none() { - return Err(anyhow!( - "No API key provided. Pass `--api-key ` or export OLLAMA_API_KEY." - )); - } - - let api_key = resolved_api_key - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| anyhow!("Ollama Cloud API key cannot be blank"))?; - - if encryption_enabled { - if let Some(manager) = credential_manager.clone() { - let credentials = ApiCredentials { - api_key: api_key.clone(), - endpoint: normalized_endpoint.clone(), - }; - manager - .store_credentials(OLLAMA_CLOUD_CREDENTIAL_ID, &credentials) - .await - .with_context(|| "Failed to store Ollama Cloud credentials securely")?; - stored_securely = true; - self.with_config_mut(|cfg| { - if let Some(entry) = cfg.providers.get_mut(&options.provider) { - entry.api_key = None; - } - }); - } else { - self.push_toast( - ToastLevel::Warning, - "Secure credential vault unavailable; storing API key in configuration.", - ); - self.with_config_mut(|cfg| { - if let Some(entry) = cfg.providers.get_mut(&options.provider) { - entry.api_key = Some(api_key.clone()); - } - }); - } - } else { - self.with_config_mut(|cfg| { - if let Some(entry) = cfg.providers.get_mut(&options.provider) { - entry.api_key = Some(api_key.clone()); - } - }); - } - - let save_result = { - let controller = self.controller_lock_async().await; - let config = controller.config(); - config::save_config(&config) - }; - if let Err(err) = save_result { - return Err(anyhow!("Failed to save configuration: {}", err)); - } - - if let Err(err) = self.refresh_models().await { - self.push_toast( - ToastLevel::Warning, - format!("Cloud setup saved, but refreshing models failed: {err}"), - ); - } - - let mut status_parts = Vec::new(); - status_parts.push(format!( - "Configured {} for Ollama Cloud ({})", - options.provider, normalized_endpoint - )); - if stored_securely { - status_parts.push("API key stored securely".to_string()); - } else { - status_parts.push("API key stored in configuration".to_string()); - } - if !base_overridden && !options.force_cloud_base_url { - status_parts.push("Local base URL preserved".to_string()); - } - - self.status = status_parts.join(" · "); - self.error = None; - Ok(()) - } - - async fn show_model_picker(&mut self, filter: Option) -> Result<()> { - self.refresh_models().await?; - - if self.models.is_empty() { - return Ok(()); - } - - // Respect caller-specified filter or fall back to the last-used mode. - if let Some(mode) = filter { - self.model_filter_memory = mode; - self.update_model_filter_mode(mode); - } else { - let remembered = self.model_filter_memory; - self.update_model_filter_mode(remembered); - } - - // Reset transient search state when opening the picker. - self.reset_model_picker_state(); - self.rebuild_model_selector_items(); - self.update_model_search_status(); - - if self.available_providers.len() <= 1 { - self.set_input_mode(InputMode::ModelSelection); - self.ensure_valid_model_selection(); - } else { - self.set_input_mode(InputMode::ProviderSelection); - } - self.status = "Select a model to use".to_string(); - Ok(()) - } - - fn best_model_match_index(&self, query: &str) -> Option { - let query = query.trim(); - if query.is_empty() { - return None; - } - - let mut best: Option<(usize, usize, usize)> = None; - for (idx, model) in self.models.iter().enumerate() { - let mut candidates = Vec::new(); - candidates.push(commands::match_score(model.id.as_str(), query)); - if !model.name.is_empty() { - candidates.push(commands::match_score(model.name.as_str(), query)); - } - candidates.push(commands::match_score( - format!("{} {}", model.provider, model.id).as_str(), - query, - )); - if !model.name.is_empty() { - candidates.push(commands::match_score( - format!("{} {}", model.provider, model.name).as_str(), - query, - )); - } - candidates.push(commands::match_score( - format!("{}::{}", model.provider, model.id).as_str(), - query, - )); - - if let Some(score) = candidates.into_iter().flatten().min() { - let entry = (score.0, score.1, idx); - let replace = match best.as_ref() { - Some(current) => entry < *current, - None => true, - }; - if replace { - best = Some(entry); - } - } - } - - best.map(|(_, _, idx)| idx) - } - - fn best_provider_match(&self, query: &str) -> Option { - let query = query.trim(); - if query.is_empty() { - return None; - } - - let mut best: Option<(usize, usize, &String)> = None; - for provider in &self.available_providers { - if let Some(score) = commands::match_score(provider.as_str(), query) { - let entry = (score.0, score.1, provider); - let replace = match best.as_ref() { - Some(current) => entry < *current, - None => true, - }; - if replace { - best = Some(entry); - } - } - } - - best.map(|(_, _, provider)| provider.clone()) - } - - async fn select_model_with_filter(&mut self, filter: &str) -> Result<()> { - let query = filter.trim(); - if query.is_empty() { - return Err(anyhow!( - "Provide a model filter (e.g. :model llama3) or omit arguments to open the picker." - )); - } - - self.refresh_models().await?; - if self.models.is_empty() { - return Err(anyhow!( - "No models available. Use :model to refresh once a provider is reachable." - )); - } - - if let Some(idx) = self.best_model_match_index(query) - && let Some(model) = self.models.get(idx).cloned() - { - self.apply_model_selection(model).await?; - return Ok(()); - } - - Err(anyhow!(format!( - "No model matching '{}'. Use :model to browse available models.", - filter - ))) - } - - fn send_user_message_and_request_response(&mut self) { - let raw = self - .controller_lock() - .input_buffer_mut() - .commit_to_history(); - let content = raw.trim().to_string(); - let attachments = std::mem::take(&mut self.pending_attachments); - - if content.is_empty() && attachments.is_empty() { - self.error = Some("Cannot send empty message".to_string()); - return; - } - - self.refresh_attachment_gallery(); - - if self.should_queue_submission() { - self.enqueue_submission(content, attachments, QueueSource::User); - return; - } - - self.start_user_turn_internal(content, attachments, QueueSource::User, 0, Utc::now()); - } - - pub fn has_active_generation(&self) -> bool { - self.pending_llm_request || !self.streaming.is_empty() - } - - pub fn cancel_active_generation(&mut self) -> Result { - let mut cancelled = false; - if self.pending_llm_request { - self.pending_llm_request = false; - cancelled = true; - } - - if let Some(token) = self.request_cancellation_token.take() { - token.cancel(); - cancelled = true; - } - - let mut cancel_error: Option = None; - - if !self.streaming.is_empty() { - let active_ids: Vec = self.streaming.iter().copied().collect(); - for message_id in active_ids { - if let Some(handle) = self.stream_tasks.remove(&message_id) { - handle.abort(); - } - if let Err(err) = self - .controller_lock() - .cancel_stream(message_id, "Generation cancelled by user.") - { - cancel_error = Some(err.to_string()); - } - self.streaming.remove(&message_id); - self.invalidate_message_cache(&message_id); - cancelled = true; - } - } - - if cancelled { - if let Some(err) = cancel_error { - self.error = Some(format!("Failed to finalize cancelled stream: {}", err)); - } else { - self.error = None; - } - self.stop_loading_animation(); - self.pending_tool_execution = None; - self.pending_consent = None; - self.queued_consents.clear(); - self.current_thinking = None; - self.agent_actions = None; - self.status = "Generation cancelled".to_string(); - self.set_system_status("Generation cancelled".to_string()); - self.update_thinking_from_last_message(); - if let Some(active) = self.active_command.take() { - if let Some(entry) = QueuedCommand::from_active(active) { - self.command_queue.push_front(entry); - } else { - self.push_toast( - ToastLevel::Warning, - "Cancelled request exceeded retry budget and was dropped.", - ); - } - } - self.start_next_queued_command(); - } - - Ok(cancelled) - } - - fn reset_after_new_conversation(&mut self) -> Result<()> { - let _ = self.cancel_active_generation()?; - self.close_code_view(); - self.set_system_status(String::new()); - self.pending_llm_request = false; - self.pending_tool_execution = None; - self.pending_consent = None; - self.queued_consents.clear(); - self.command_queue.clear(); - self.active_command = None; - self.latest_thought_summary = None; - self.thought_summaries.clear(); - self.keymap_state.reset(); - self.visual_start = None; - self.visual_end = None; - self.clipboard.clear(); - - { - let mut controller = self.controller_lock(); - let buffer = controller.input_buffer_mut(); - buffer.clear(); - buffer.clear_history(); - } - - self.textarea = TextArea::default(); - configure_textarea_defaults(&mut self.textarea); - - self.auto_scroll = AutoScroll::default(); - self.thinking_scroll = AutoScroll::default(); - - self.chat_cursor = (0, 0); - self.thinking_cursor = (0, 0); - - self.current_thinking = None; - self.agent_actions = None; - self.agent_mode = false; - self.agent_running = false; - self.is_loading = false; - self.message_line_cache.clear(); - - // Ensure no orphaned stream tasks remain - for (_, handle) in self.stream_tasks.drain() { - handle.abort(); - } - self.streaming.clear(); - - self.focused_panel = FocusedPanel::Input; - self.ensure_focus_valid(); - - Ok(()) - } - - pub async fn process_pending_llm_request(&mut self) -> Result<()> { - if !self.pending_llm_request { - return Ok(()); - } - - self.pending_llm_request = false; - - self.resolve_pending_resource_references().await?; - - if self.agent_mode { - return self.process_agent_request().await; - } - - let controller = self.controller.clone(); - let session_tx = self.session_tx.clone(); - let token = CancellationToken::new(); - self.request_cancellation_token = Some(token.clone()); - - self.status = "Sending request...".to_string(); - self.start_loading_animation(); - - tokio::spawn(async move { - let mut controller_guard = controller.lock().await; - let stream_enabled = controller_guard.config().general.enable_streaming; - let parameters = ChatParameters { - stream: stream_enabled, - ..Default::default() - }; - let request_future = - controller_guard.send_request_with_current_conversation(parameters); - tokio::pin!(request_future); - - let timeout_duration = std::time::Duration::from_secs(30); - - tokio::select! { - _ = token.cancelled() => { - // The request was cancelled - } - outcome = tokio::time::timeout(timeout_duration, request_future) => { - match outcome { - Ok(Ok(SessionOutcome::Complete(response))) => { - let _ = session_tx.send(SessionEvent::StreamChunk { - message_id: response.message.id, - response, - }); - } - Ok(Ok(SessionOutcome::Streaming { - response_id, - mut stream, - })) => { - while let Some(result) = stream.next().await { - match result { - Ok(response) => { - if session_tx - .send(SessionEvent::StreamChunk { - message_id: response_id, - response, - }) - .is_err() - { - break; - } - } - Err(e) => { - let _ = session_tx.send(SessionEvent::StreamError { - message_id: Some(response_id), - message: e.to_string(), - error: Some(e), - }); - break; - } - } - } - } - Ok(Err(e)) => { - let _ = session_tx.send(SessionEvent::StreamError { - message_id: None, - message: e.to_string(), - error: Some(e), - }); - } - Err(_) => { // Timeout - let _ = session_tx.send(SessionEvent::StreamError { - message_id: None, - message: "Request timed out. Check if Ollama is running.".to_string(), - error: None, - }); - } - } - } - } - }); - - Ok(()) - } - - async fn handle_cloud_unauthorized( - &mut self, - summary: impl Into, - detail: Option<&str>, - ) -> Result { - let summary = summary.into(); - let error_text = detail - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(|detail| format!("{summary}: {detail}")) - .unwrap_or(summary.clone()); - - self.push_toast( - ToastLevel::Error, - "Cloud key invalid; using local provider.", - ); - - let switch_result = self.switch_to_provider("ollama_local").await; - if let Err(switch_err) = switch_result { - let detail_message = format!("{summary}; local fallback failed: {}", switch_err); - self.error = Some(detail_message.clone()); - self.status = "Cloud authentication failed".to_string(); - self.push_toast(ToastLevel::Error, detail_message); - } else { - self.selected_provider = "ollama_local".to_string(); - self.expanded_provider = Some("ollama_local".to_string()); - self.update_selected_provider_index(); - - self.with_config_mut(|cfg| { - cfg.general.default_provider = "ollama_local".to_string(); - }); - - let save_result = self.with_config(config::save_config); - if let Err(save_err) = save_result { - self.push_toast( - ToastLevel::Warning, - format!( - "Fell back to local provider, but failed to save config: {}", - save_err - ), - ); - } - - if let Err(refresh_err) = self.refresh_models().await { - self.push_toast( - ToastLevel::Warning, - format!("Failed to refresh local models: {}", refresh_err), - ); - } - - self.status = "Cloud authentication failed; using local provider instead.".to_string(); - self.error = Some(error_text); - self.push_toast(ToastLevel::Info, "Switched back to local provider."); - } - - Ok(true) - } - - async fn handle_provider_error(&mut self, err: &CoreError) -> Result { - let current_provider = Self::canonical_provider_id(&self.current_provider); - if current_provider != "ollama_cloud" { - return Ok(false); - } - - if let CoreError::ProviderFailure(failure) = err { - match failure.kind { - ProviderErrorKind::Unauthorized => { - let detail = failure.detail.as_deref(); - let message = if detail.is_some() { - failure.message.clone() - } else { - "Cloud key invalid".to_string() - }; - return self.handle_cloud_unauthorized(message, detail).await; - } - ProviderErrorKind::RateLimited => { - let toast = failure.message.clone(); - self.error = Some( - failure - .detail - .as_ref() - .filter(|d| !d.trim().is_empty()) - .map(|detail| format!("{toast}: {detail}")) - .unwrap_or(toast.clone()), - ); - self.status = "Cloud rate limit hit".to_string(); - self.push_toast(ToastLevel::Warning, toast); - return Ok(true); - } - ProviderErrorKind::ModelNotFound => { - self.error = Some(failure.message.clone()); - self.status = "Model unavailable".to_string(); - let _ = self.refresh_models().await; - self.set_input_mode(InputMode::ProviderSelection); - return Ok(true); - } - ProviderErrorKind::Timeout => { - self.error = Some(failure.message.clone()); - self.status = "Request timed out".to_string(); - self.push_toast(ToastLevel::Warning, failure.message.clone()); - return Ok(true); - } - ProviderErrorKind::Unavailable => { - self.error = Some(failure.message.clone()); - self.status = "Cloud provider unavailable".to_string(); - self.push_toast(ToastLevel::Warning, failure.message.clone()); - return Ok(true); - } - ProviderErrorKind::Network => { - self.error = Some( - failure - .detail - .clone() - .unwrap_or_else(|| failure.message.clone()), - ); - self.status = "Network error".to_string(); - self.push_toast(ToastLevel::Warning, failure.message.clone()); - return Ok(true); - } - _ => { - self.error = Some( - failure - .detail - .clone() - .unwrap_or_else(|| failure.message.clone()), - ); - self.status = "Request failed".to_string(); - return Ok(true); - } - } - } - - match err { - CoreError::Auth(message) => { - self.handle_cloud_unauthorized("Cloud key invalid", Some(message.as_str())) - .await - } - CoreError::Network(message) => { - self.error = Some(message.clone()); - self.status = "Network error".to_string(); - self.push_toast(ToastLevel::Warning, "Network error talking to provider."); - Ok(true) - } - _ => Ok(false), - } - } - - async fn process_agent_request(&mut self) -> Result<()> { - use owlen_core::agent::{AgentConfig, AgentExecutor}; - use owlen_core::mcp::remote_client::RemoteMcpClient; - use std::sync::Arc; - - // Get the last user message (including attachments) - let user_message = self - .conversation() - .messages - .iter() - .rev() - .find(|m| m.role == owlen_core::types::Role::User) - .cloned() - .unwrap_or_else(|| owlen_core::types::Message::user(String::new())); - - let user_prompt = user_message.content.clone(); - let user_attachments = user_message.attachments.clone(); - - let profile = match self.active_agent_profile().cloned() { - Some(profile) => profile, - None => { - if self.agent_registry.profiles().is_empty() { - self.error = Some( - "No agent profiles configured. Add files under .owlen/agents or ~/.config/owlen/agents.".to_string(), - ); - } else { - self.error = Some( - "No active agent selected. Use :agent use to choose one.".to_string(), - ); - } - self.agent_running = false; - self.agent_mode = false; - self.stop_loading_animation(); - return Ok(()); - } - }; - - let selected_model = self.selected_model(); - let mut config = AgentConfig { - model: profile.model.clone().unwrap_or(selected_model), - system_prompt: Some(profile.system_prompt.clone()), - sub_agents: profile.sub_agents.clone(), - ..AgentConfig::default() - }; - if let Some(iterations) = profile.max_iterations { - config.max_iterations = iterations; - } - if let Some(temp) = profile.temperature { - config.temperature = Some(temp); - } - if let Some(max_tokens) = profile.max_tokens { - config.max_tokens = Some(max_tokens); - } - - let agent_label = profile.display_name().to_string(); - - self.agent_running = true; - self.status = format!("Agent '{}' is running...", agent_label); - self.error = None; - self.set_system_status(format!("🤖 Working · {}", agent_label)); - self.start_loading_animation(); - - // Get the provider - let provider = { - let controller = self.controller_lock_async().await; - controller.provider().clone() - }; - - // Create MCP client - let mcp_client = match RemoteMcpClient::new().await { - Ok(client) => Arc::new(client), - Err(e) => { - self.error = Some(format!("Failed to initialize MCP client: {}", e)); - self.agent_running = false; - self.agent_mode = false; - self.stop_loading_animation(); - return Ok(()); - } - }; - - // Create agent executor - let executor = AgentExecutor::new(provider, mcp_client, config); - - // Run agent - match executor - .run_with_attachments(user_prompt, user_attachments) - .await - { - Ok(result) => { - let message_id = self.push_assistant_message(result.answer); - self.agent_running = false; - self.agent_mode = false; - self.agent_actions = None; - self.status = format!( - "Agent '{}' completed in {} iterations", - agent_label, result.iterations - ); - self.set_system_status(format!("🤖 Complete · {}", agent_label)); - self.stop_loading_animation(); - if let Some(active) = self.active_command.as_mut() { - active.record_response(message_id); - } - self.update_thinking_from_last_message(); - self.mark_active_command_succeeded(); - Ok(()) - } - Err(e) => { - let message = format!("Agent '{}' failed: {}", agent_label, e); - self.error = Some(message.clone()); - self.agent_running = false; - self.agent_mode = false; - self.agent_actions = None; - self.stop_loading_animation(); - self.set_system_status(format!("🤖 Failed · {}", agent_label)); - self.mark_active_command_failed(Some(message)); - Ok(()) - } - } - } - - pub async fn process_pending_tool_execution(&mut self) -> Result<()> { - let Some((message_id, tool_calls)) = self.pending_tool_execution.take() else { - return Ok(()); - }; - - // If a consent dialog is active, keep the execution queued until it resolves - if self.pending_consent.is_some() { - self.pending_tool_execution = Some((message_id, tool_calls)); - return Ok(()); - } - - // Check if consent is needed for any of these tools - let consent_needed = { - let controller = self.controller_lock_async().await; - controller.check_tools_consent_needed(&tool_calls) - }; - - if !consent_needed.is_empty() { - // Re-queue the execution and ensure a controller event is emitted - self.pending_tool_execution = Some((message_id, tool_calls)); - let mut controller = self.controller_lock_async().await; - controller.check_streaming_tool_calls(message_id); - return Ok(()); - } - - // Show tool execution status - self.status = format!("🔧 Executing {} tool(s)...", tool_calls.len()); - - // Show tool names in system output - let tool_names: Vec = tool_calls.iter().map(|tc| tc.name.clone()).collect(); - self.set_system_status(format!("🔧 Executing tools: {}", tool_names.join(", "))); - - self.start_loading_animation(); - - // Execute tools and get the result - let execution_result = { - let mut controller = self.controller_lock_async().await; - controller - .execute_streaming_tools(message_id, tool_calls) - .await - }; - - match execution_result { - Ok(SessionOutcome::Streaming { - response_id, - stream, - }) => { - // Tool execution succeeded, spawn stream handler for continuation - self.status = "Tool results sent. Generating response...".to_string(); - self.set_system_status("✓ Tools executed successfully".to_string()); - self.spawn_stream(response_id, stream); - if let Some(active) = self.active_command.as_mut() { - active.record_response(response_id); - } - let placeholder_result = { - let mut controller = self.controller_lock_async().await; - controller.mark_stream_placeholder(response_id, "▌") - }; - match placeholder_result { - Ok(_) => self.error = None, - Err(err) => { - self.error = Some(format!("Could not set response placeholder: {}", err)); - } - } - Ok(()) - } - Ok(SessionOutcome::Complete(response)) => { - // Tool execution complete without streaming (shouldn't happen in streaming mode) - self.stop_loading_animation(); - self.status = "✓ Tool execution complete".to_string(); - self.set_system_status("✓ Tool execution complete".to_string()); - self.error = None; - if let Some(active) = self.active_command.as_mut() { - active.record_response(response.message.id); - } - self.update_thinking_from_last_message(); - self.mark_active_command_succeeded(); - Ok(()) - } - Err(err) => { - self.stop_loading_animation(); - self.status = "Tool execution failed".to_string(); - let failure_message = format!("❌ Tool execution failed: {}", err); - self.set_system_status(failure_message.clone()); - self.error = Some(failure_message.clone()); - self.mark_active_command_failed(Some(failure_message)); - Ok(()) - } - } - } - - // Updated to async to allow awaiting async controller calls - async fn sync_selected_model_index(&mut self) { - self.expanded_provider = Some(self.selected_provider.clone()); - self.rebuild_model_selector_items(); - - let current_model_id = self.selected_model(); - let mut config_updated = false; - - if let Some(idx) = self.index_of_model_id(¤t_model_id) { - self.set_selected_model_item(idx); - } else { - if let Some(idx) = self.index_of_first_model_for_provider(&self.selected_provider) { - self.set_selected_model_item(idx); - } else if let Some(idx) = self.index_of_header(&self.selected_provider) { - self.set_selected_model_item(idx); - } else if let Some(idx) = self.first_model_item_index() { - self.set_selected_model_item(idx); - } else { - self.ensure_valid_model_selection(); - } - - if let Some(model) = self.selected_model_info().cloned() { - self.selected_provider = model.provider.clone(); - // Set the selected model asynchronously - let mut controller = self.controller_lock_async().await; - controller.set_model(model.id.clone()).await; - { - let mut config = controller.config_mut(); - config.general.default_model = Some(model.id.clone()); - config.general.default_provider = self.selected_provider.clone(); - } - drop(controller); - config_updated = true; - } - } - - self.update_selected_provider_index(); - - if config_updated { - let save_result = { - let controller = self.controller_lock_async().await; - let config = controller.config(); - config::save_config(&config) - }; - if let Err(err) = save_result { - self.error = Some(format!("Failed to save config: {err}")); - } else { - self.error = None; - } - } - } - - pub fn set_viewport_dimensions(&mut self, height: usize, content_width: usize) { - self.viewport_height = height; - self.content_width = content_width; - } - - pub fn set_thinking_viewport_height(&mut self, height: usize) { - self.thinking_viewport_height = height; - } - - pub fn start_loading_animation(&mut self) { - self.is_loading = true; - self.loading_animation_frame = 0; - } - - pub fn stop_loading_animation(&mut self) { - self.is_loading = false; - } - - pub fn advance_loading_animation(&mut self) { - if self.is_loading { - self.loading_animation_frame = (self.loading_animation_frame + 1) % 8; - // 8-frame animation - } - } - - pub fn get_loading_indicator(&self) -> &'static str { - if !self.is_loading { - return ""; - } - - match self.loading_animation_frame { - 0 => "⠋", - 1 => "⠙", - 2 => "⠹", - 3 => "⠸", - 4 => "⠼", - 5 => "⠴", - 6 => "⠦", - 7 => "⠧", - _ => "⠋", - } - } - - pub fn current_thinking(&self) -> Option<&String> { - self.current_thinking.as_ref() - } - - /// Get a reference to the latest agent actions, if any. - pub fn agent_actions(&self) -> Option<&String> { - self.agent_actions.as_ref() - } - - /// Set the current agent actions content. - pub fn set_agent_actions(&mut self, actions: String) { - self.agent_actions = Some(actions); - } - - /// Check if agent mode is enabled - pub fn is_agent_mode(&self) -> bool { - self.agent_mode - } - - /// Check if agent is currently running - pub fn is_agent_running(&self) -> bool { - self.agent_running - } - - pub fn get_rendered_lines(&self) -> Vec { - match self.focused_panel { - FocusedPanel::Chat => { - let conversation = self.conversation(); - let mut formatter = self.formatter(); - let body_width = self.content_width.max(1); - let mut card_width = body_width.saturating_add(4); - let mut compact_cards = false; - if card_width < MIN_MESSAGE_CARD_WIDTH { - card_width = body_width.saturating_add(2).max(1); - compact_cards = true; - } - let inner_width = if compact_cards { - card_width.saturating_sub(2).max(1) - } else { - card_width.saturating_sub(4).max(1) - }; - formatter.set_wrap_width(body_width); - let role_label_mode = formatter.role_label_mode(); - - let mut lines = Vec::new(); - - for message in conversation.messages.iter() { - let role = &message.role; - let content_to_display = if matches!(role, Role::Assistant) { - let (content_without_think, _) = - formatter.extract_thinking(&message.content); - content_without_think - } else if matches!(role, Role::Tool) { - format_tool_output(&message.content) - } else { - message.content.clone() - }; - - let is_streaming = message - .metadata - .get("streaming") - .and_then(|value| value.as_bool()) - .unwrap_or(false); - - let normalized_content = content_to_display.replace("\r\n", "\n"); - let trimmed = normalized_content.trim(); - let segments = parse_message_segments(trimmed, self.render_markdown); - - let mut body_lines: Vec = Vec::new(); - let mut indicator_target: Option = None; - - let mut append_segments_plain = - |segments: &[MessageSegment], - indent: &str, - available_width: usize, - indicator_target: &mut Option, - code_width: usize| { - if segments.is_empty() { - let line_text = if indent.is_empty() { - String::new() - } else { - indent.to_string() - }; - body_lines.push(line_text); - *indicator_target = Some(body_lines.len() - 1); - return; - } - - for segment in segments { - match segment { - MessageSegment::Text { lines: seg_lines } => { - for line_text in seg_lines { - let mut chunks = - wrap_unicode(line_text.as_str(), available_width); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - let text = if indent.is_empty() { - chunk.clone() - } else { - format!("{indent}{chunk}") - }; - body_lines.push(text); - *indicator_target = Some(body_lines.len() - 1); - } - } - } - MessageSegment::CodeBlock { - language, - lines: code_lines, - } => { - append_code_block_lines_plain( - &mut body_lines, - indent, - code_width, - language.as_deref(), - code_lines, - indicator_target, - ); - } - } - } - }; - - match role_label_mode { - RoleLabelDisplay::Above => { - let indent = " "; - let indent_width = UnicodeWidthStr::width(indent); - let available_width = body_width.saturating_sub(indent_width).max(1); - append_segments_plain( - &segments, - indent, - available_width, - &mut indicator_target, - body_width.saturating_sub(indent_width), - ); - } - RoleLabelDisplay::Inline | RoleLabelDisplay::None => { - let indent = ""; - let available_width = body_width.max(1); - append_segments_plain( - &segments, - indent, - available_width, - &mut indicator_target, - body_width, - ); - } - } - - let loading_indicator = self.get_loading_indicator(); - if is_streaming && !loading_indicator.is_empty() { - let spinner_symbol = streaming_indicator_symbol(loading_indicator); - if let Some(idx) = indicator_target { - if let Some(line) = body_lines.get_mut(idx) { - if !line.is_empty() { - line.push(' '); - } - line.push_str(spinner_symbol); - } - } else if let Some(line) = body_lines.last_mut() { - if !line.is_empty() { - line.push(' '); - } - line.push_str(spinner_symbol); - } else { - body_lines.push(spinner_symbol.to_string()); - } - } - - let formatted_timestamp = if self.show_message_timestamps { - Some(Self::format_message_timestamp(message.timestamp)) - } else { - None - }; - let markers = Self::message_tool_markers( - role, - message.tool_calls.as_ref(), - message - .metadata - .get("tool_call_id") - .and_then(|value| value.as_str()), - ); - - if compact_cards { - let (emoji, title) = role_label_parts(role); - let mut header = format!("{emoji} {title}"); - if let Some(ts) = formatted_timestamp.as_deref() { - header.push_str(" · "); - header.push_str(ts); - } - for marker in &markers { - header.push(' '); - header.push_str(marker); - } - lines.push(header); - - if body_lines.is_empty() { - lines.push(String::new()); - } else { - lines.extend(body_lines); - } - - lines.push(String::new()); - } else { - lines.push(Self::build_card_header_plain( - role, - formatted_timestamp.as_deref(), - &markers, - card_width, - )); - - if body_lines.is_empty() { - lines.push(Self::wrap_card_body_line_plain("", inner_width)); - } else { - for body_line in body_lines { - lines - .push(Self::wrap_card_body_line_plain(&body_line, inner_width)); - } - } - - lines.push(Self::build_card_footer_plain(card_width)); - } - } - let last_message_is_user = conversation - .messages - .last() - .map(|msg| matches!(msg.role, Role::User)) - .unwrap_or(true); - - if !self.get_loading_indicator().is_empty() && last_message_is_user { - lines.push(format!("🤖 Assistant: {}", self.get_loading_indicator())); - } - - if self.chat_line_offset > 0 { - let skip = self.chat_line_offset.min(lines.len()); - lines = lines.into_iter().skip(skip).collect(); - } - - if lines.is_empty() { - lines.push("No messages yet. Press 'i' to start typing.".to_string()); - } - - lines - } - FocusedPanel::Thinking => { - if let Some(thinking) = &self.current_thinking { - thinking.lines().map(|s| s.to_string()).collect() - } else { - Vec::new() - } - } - FocusedPanel::Files => Vec::new(), - FocusedPanel::Code => { - if self.has_loaded_code_view() { - self.code_view_lines() - .iter() - .enumerate() - .map(|(idx, line)| format!("{:>4} {}", idx + 1, line)) - .collect() - } else { - Vec::new() - } - } - FocusedPanel::Input => Vec::new(), - } - } - - fn get_line_at_row(&self, row: usize) -> Option { - self.get_rendered_lines().get(row).cloned() - } - - fn find_next_word_boundary(&self, row: usize, col: usize) -> Option { - let line = self.get_line_at_row(row)?; - owlen_core::ui::find_next_word_boundary(&line, col) - } - - fn find_word_end(&self, row: usize, col: usize) -> Option { - let line = self.get_line_at_row(row)?; - owlen_core::ui::find_word_end(&line, col) - } - - fn find_prev_word_boundary(&self, row: usize, col: usize) -> Option { - let line = self.get_line_at_row(row)?; - owlen_core::ui::find_prev_word_boundary(&line, col) - } - - fn yank_from_panel(&self) -> Option { - let (start_pos, end_pos) = if let (Some(s), Some(e)) = (self.visual_start, self.visual_end) - { - // Normalize selection - if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) { - (s, e) - } else { - (e, s) - } - } else { - return None; - }; - - let lines = self.get_rendered_lines(); - owlen_core::ui::extract_text_from_selection(&lines, start_pos, end_pos) - } - - pub fn update_thinking_from_last_message(&mut self) { - // Extract thinking from the last assistant message - if let Some(last_msg) = self - .conversation() - .messages - .iter() - .rev() - .find(|m| matches!(m.role, Role::Assistant)) - { - let formatter = self.formatter(); - let (_, thinking) = formatter.extract_thinking(&last_msg.content); - // Only set stick_to_bottom if content actually changed (to enable auto-scroll during streaming) - let content_changed = self.current_thinking != thinking; - self.current_thinking = thinking; - if content_changed { - // Auto-scroll thinking panel to bottom when content updates - self.thinking_scroll.stick_to_bottom = true; - } - } else { - self.current_thinking = None; - // If thinking panel was focused but thinking disappeared, switch to Chat - if matches!(self.focused_panel, FocusedPanel::Thinking) { - self.focused_panel = FocusedPanel::Chat; - } - } - } - - fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::ChatStream) { - let sender = self.session_tx.clone(); - self.streaming.insert(message_id); - - let handle = tokio::spawn(async move { - use futures_util::StreamExt; - - while let Some(item) = stream.next().await { - match item { - Ok(response) => { - if sender - .send(SessionEvent::StreamChunk { - message_id, - response, - }) - .is_err() - { - break; - } - } - Err(e) => { - let _ = sender.send(SessionEvent::StreamError { - message_id: Some(message_id), - message: e.to_string(), - error: Some(e), - }); - break; - } - } - } - }); - - self.stream_tasks.insert(message_id, handle); - } -} - -fn capitalize_first(input: &str) -> String { - let mut chars = input.chars(); - if let Some(first) = chars.next() { - let mut result = first.to_uppercase().collect::(); - result.push_str(chars.as_str()); - result - } else { - String::new() - } -} - -impl ChatApp { - fn summarize_thinking(thinking: &str) -> Option { - let mut segments = thinking - .split('\n') - .map(|line| line.trim()) - .filter(|line| !line.is_empty()); - - let mut summary = segments.next()?.to_string(); - - if summary.chars().count() < 120 - && let Some(next) = segments.next() - && !next.is_empty() - { - if !summary.ends_with('.') && !summary.ends_with('!') && !summary.ends_with('?') - { - summary.push('.'); - } - summary.push(' '); - summary.push_str(next); - } - - if summary.chars().count() > 160 { - summary = Self::truncate_to_chars(&summary, 160); - } - - Some(summary) - } - - fn truncate_to_chars(input: &str, limit: usize) -> String { - if input.chars().count() <= limit { - return input.to_string(); - } - - let mut result = String::new(); - for (idx, ch) in input.chars().enumerate() { - if idx + 1 >= limit { - result.push('…'); - break; - } - result.push(ch); - } - result - } -} - -pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) { - match role { - Role::User => ("👤", "You"), - Role::Assistant => ("🤖", "Assistant"), - Role::System => ("⚙️", "System"), - Role::Tool => ("🔧", "Tool"), - } -} - -pub(crate) fn max_inline_label_width() -> usize { - [ - ("👤", "You"), - ("🤖", "Assistant"), - ("⚙️", "System"), - ("🔧", "Tool"), - ] - .iter() - .map(|(emoji, title)| { - let measure = format!("{emoji} {title}:"); - UnicodeWidthStr::width(measure.as_str()) - }) - .max() - .unwrap_or(0) -} - -pub(crate) fn streaming_indicator_symbol(indicator: &str) -> &str { - if indicator.is_empty() { - "▌" - } else { - indicator - } -} - -fn parse_message_segments(content: &str, markdown_enabled: bool) -> Vec { - if !markdown_enabled { - let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); - if lines.is_empty() { - lines.push(String::new()); - } - return vec![MessageSegment::Text { lines }]; - } - - let mut segments = Vec::new(); - let mut text_lines: Vec = Vec::new(); - let mut lines = content.lines(); - - while let Some(line) = lines.next() { - let trimmed = line.trim_start(); - if trimmed.starts_with("```") { - if !text_lines.is_empty() { - segments.push(MessageSegment::Text { - lines: std::mem::take(&mut text_lines), - }); - } - - let language = trimmed - .trim_start_matches("```") - .split_whitespace() - .next() - .unwrap_or("") - .to_string(); - - let mut code_lines = Vec::new(); - for code_line in lines.by_ref() { - if code_line.trim_start().starts_with("```") { - break; - } - code_lines.push(code_line.to_string()); - } - - segments.push(MessageSegment::CodeBlock { - language: if language.is_empty() { - None - } else { - Some(language) - }, - lines: code_lines, - }); - } else { - text_lines.push(line.to_string()); - } - } - - if !text_lines.is_empty() { - segments.push(MessageSegment::Text { lines: text_lines }); - } else if segments.is_empty() { - segments.push(MessageSegment::Text { - lines: vec![String::new()], - }); - } - - segments -} - -fn wrap_code(text: &str, width: usize) -> Vec { - if width == 0 { - return vec![String::new()]; - } - - let options = Options::new(width) - .word_separator(WordSeparator::UnicodeBreakProperties) - .break_words(true); - - let mut wrapped: Vec = wrap(text, options) - .into_iter() - .map(|segment| segment.into_owned()) - .collect(); - - if wrapped.is_empty() { - wrapped.push(String::new()); - } - - wrapped -} - -fn render_markdown_lines( - markdown: &str, - indent: &str, - available_width: usize, - base_style: Style, -) -> Vec> { - let width = available_width.max(1); - let lines: Vec<&str> = if markdown.is_empty() { - Vec::new() - } else { - markdown.lines().collect() - }; - - if lines.is_empty() { - return render_markdown_text_block(markdown, indent, width, base_style); - } - - let mut output: Vec> = Vec::new(); - let mut buffer: Vec<&str> = Vec::new(); - let mut index = 0usize; - - while index < lines.len() { - if let Some((table, next_index)) = parse_markdown_table(&lines, index) { - if !buffer.is_empty() { - let block = buffer.join("\n"); - output.extend(render_markdown_text_block( - &block, indent, width, base_style, - )); - buffer.clear(); - } - output.extend(render_markdown_table(&table, indent, width, base_style)); - index = next_index; - } else { - buffer.push(lines[index]); - index += 1; - } - } - - if !buffer.is_empty() { - let block = buffer.join("\n"); - output.extend(render_markdown_text_block( - &block, indent, width, base_style, - )); - } - - if output.is_empty() { - output.extend(render_markdown_text_block("", indent, width, base_style)); - } - - output -} - -fn render_markdown_text_block( - markdown: &str, - indent: &str, - width: usize, - base_style: Style, -) -> Vec> { - let mut text = from_str(markdown); - let mut output: Vec> = Vec::new(); - - if text.lines.is_empty() { - let wrapped = wrap_markdown_spans(Vec::new(), indent, width, base_style); - output.extend(wrapped); - } else { - for line in text.lines.drain(..) { - let spans_owned = line - .spans - .into_iter() - .map(|span| { - let owned = span.content.into_owned(); - Span::styled(owned, span.style) - }) - .collect::>(); - let wrapped = wrap_markdown_spans(spans_owned, indent, width, base_style); - output.extend(wrapped); - } - } - - if output.is_empty() { - output.push(blank_line(indent, base_style)); - } - - output -} - -#[derive(Debug)] -struct ParsedTable { - headers: Vec, - rows: Vec>, - alignments: Vec, -} - -#[derive(Clone, Copy, Debug)] -enum TableAlignment { - Left, - Center, - Right, -} - -fn parse_markdown_table(lines: &[&str], start: usize) -> Option<(ParsedTable, usize)> { - if start + 1 >= lines.len() { - return None; - } - - let header_line = lines[start].trim(); - let alignment_line = lines[start + 1].trim(); - - if header_line.is_empty() || !header_line.contains('|') { - return None; - } - - let headers = split_table_row(header_line); - if headers.is_empty() { - return None; - } - - let alignments = parse_alignment_row(alignment_line, headers.len())?; - let mut rows = Vec::new(); - let mut index = start + 2; - - while index < lines.len() { - let raw = lines[index]; - let trimmed = raw.trim(); - - if trimmed.is_empty() || !trimmed.contains('|') { - break; - } - - if trimmed - .chars() - .all(|ch| matches!(ch, '-' | ':' | '|' | ' ')) - { - break; - } - - let mut cells = split_table_row(trimmed); - if cells.iter().all(|cell| cell.is_empty()) { - break; - } - - if cells.len() < headers.len() { - cells.resize(headers.len(), String::new()); - } else if cells.len() > headers.len() { - cells.truncate(headers.len()); - } - - rows.push(cells); - index += 1; - } - - Some(( - ParsedTable { - headers, - rows, - alignments, - }, - index, - )) -} - -fn split_table_row(line: &str) -> Vec { - let trimmed = line.trim(); - if trimmed.is_empty() { - return vec![String::new()]; - } - - let mut chars = trimmed.chars().peekable(); - // Discard a single leading pipe if present. - if matches!(chars.peek(), Some('|')) { - chars.next(); - } - - let mut cells = Vec::new(); - let mut current = String::new(); - let mut escape = false; - - for ch in chars { - if escape { - current.push(ch); - escape = false; - continue; - } - match ch { - '\\' => { - escape = true; - } - '|' => { - cells.push(current.trim().to_string()); - current.clear(); - } - _ => current.push(ch), - } - } - - if escape { - current.push('\\'); - } - - if !trimmed.ends_with('|') || !current.trim().is_empty() { - cells.push(current.trim().to_string()); - } - - if cells.is_empty() { - cells.push(String::new()); - } - - cells -} - -fn parse_alignment_row(line: &str, expected_columns: usize) -> Option> { - let trimmed = line.trim(); - if trimmed.is_empty() || !trimmed.contains('-') { - return None; - } - - let raw_cells = split_table_row(trimmed); - if raw_cells.len() != expected_columns { - return None; - } - - let mut alignments = Vec::with_capacity(expected_columns); - for cell in raw_cells { - let cell_trimmed = cell.trim(); - if cell_trimmed.is_empty() { - alignments.push(TableAlignment::Left); - continue; - } - - if !cell_trimmed.chars().all(|ch| matches!(ch, '-' | ':' | ' ')) { - return None; - } - - if !cell_trimmed.contains('-') { - return None; - } - - let left = cell_trimmed.starts_with(':'); - let right = cell_trimmed.ends_with(':'); - let alignment = match (left, right) { - (true, true) => TableAlignment::Center, - (false, true) => TableAlignment::Right, - _ => TableAlignment::Left, - }; - alignments.push(alignment); - } - - Some(alignments) -} - -fn render_markdown_table( - table: &ParsedTable, - indent: &str, - available_width: usize, - base_style: Style, -) -> Vec> { - const MIN_CELL_WIDTH: usize = 3; - - if table.headers.is_empty() { - return render_markdown_text_block("", indent, available_width.max(1), base_style); - } - - let indent_width = UnicodeWidthStr::width(indent); - let padding_cost = 1 + table.headers.len() * 3; - let available_content = available_width.saturating_sub(indent_width + padding_cost); - - if available_content < table.headers.len().saturating_mul(MIN_CELL_WIDTH) { - return render_markdown_table_stacked(table, indent, available_width, base_style); - } - - let mut desired_widths = vec![MIN_CELL_WIDTH; table.headers.len()]; - for (index, header) in table.headers.iter().enumerate() { - desired_widths[index] = desired_widths[index].max(cell_display_width(header)); - } - for row in &table.rows { - for (index, cell) in row.iter().enumerate().take(desired_widths.len()) { - desired_widths[index] = desired_widths[index].max(cell_display_width(cell)); - } - } - - let constrained = constrain_column_widths(&desired_widths, available_content, MIN_CELL_WIDTH) - .unwrap_or_else(|| vec![MIN_CELL_WIDTH; table.headers.len()]); - - if constrained.iter().sum::() > available_content { - return render_markdown_table_stacked(table, indent, available_width, base_style); - } - - render_markdown_table_grid(table, indent, available_width, base_style, &constrained) -} - -fn render_markdown_table_grid( - table: &ParsedTable, - indent: &str, - available_width: usize, - base_style: Style, - column_widths: &[usize], -) -> Vec> { - let mut output = Vec::new(); - output.extend(render_table_summary_lines( - table, - indent, - available_width, - base_style, - )); - output.push(blank_line(indent, base_style)); - - output.push(build_table_border_line( - '┌', - '┬', - '┐', - column_widths, - indent, - base_style, - )); - - let header_styles = vec![base_style.add_modifier(Modifier::BOLD); table.headers.len()]; - output.extend(render_table_row_lines( - &table.headers, - column_widths, - &table.alignments, - indent, - base_style, - &header_styles, - )); - - if table.rows.is_empty() { - output.push(build_table_border_line( - '└', - '┴', - '┘', - column_widths, - indent, - base_style, - )); - return output; - } - - output.push(build_table_border_line( - '├', - '┼', - '┤', - column_widths, - indent, - base_style, - )); - - let body_styles = vec![base_style; table.headers.len()]; - for (index, row) in table.rows.iter().enumerate() { - output.extend(render_table_row_lines( - row, - column_widths, - &table.alignments, - indent, - base_style, - &body_styles, - )); - - if index == table.rows.len() - 1 { - output.push(build_table_border_line( - '└', - '┴', - '┘', - column_widths, - indent, - base_style, - )); - } else { - output.push(build_table_border_line( - '├', - '┼', - '┤', - column_widths, - indent, - base_style, - )); - } - } - - output -} - -fn render_markdown_table_stacked( - table: &ParsedTable, - indent: &str, - available_width: usize, - base_style: Style, -) -> Vec> { - let mut output = Vec::new(); - output.extend(render_table_summary_lines( - table, - indent, - available_width, - base_style, - )); - - if table.rows.is_empty() { - output.push(blank_line(indent, base_style)); - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), base_style)); - } - spans.push(Span::styled( - "(No rows)", - base_style.add_modifier(Modifier::ITALIC), - )); - output.push(Line::from(spans)); - return output; - } - - output.push(blank_line(indent, base_style)); - - let indent_width = UnicodeWidthStr::width(indent); - let available = available_width.saturating_sub(indent_width).max(1); - let header_style = base_style.add_modifier(Modifier::BOLD); - - for (row_index, row) in table.rows.iter().enumerate() { - if row_index > 0 { - output.push(blank_line(indent, base_style)); - } - - for (column_index, header) in table.headers.iter().enumerate() { - let value = row.get(column_index).map(String::as_str).unwrap_or(""); - let bullet_prefix = if column_index == 0 { - format!("• {}: ", header) - } else { - format!(" {}: ", header) - }; - let prefix_width = UnicodeWidthStr::width(bullet_prefix.as_str()); - let value_width = available.saturating_sub(prefix_width); - - if value_width == 0 { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), base_style)); - } - spans.push(Span::styled(bullet_prefix.clone(), header_style)); - output.push(Line::from(spans)); - - let wrapped_values = wrap_table_cell(value, available.max(1)); - for wrapped in wrapped_values { - let mut continuation = Vec::new(); - if !indent.is_empty() { - continuation.push(Span::styled(indent.to_string(), base_style)); - } - continuation.push(Span::styled(" ".repeat(prefix_width), header_style)); - continuation.push(Span::styled(wrapped, base_style)); - output.push(Line::from(continuation)); - } - continue; - } - - let wrapped_values = wrap_table_cell(value, value_width.max(1)); - for (line_index, wrapped) in wrapped_values.into_iter().enumerate() { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), base_style)); - } - if line_index == 0 { - spans.push(Span::styled(bullet_prefix.clone(), header_style)); - } else { - spans.push(Span::styled(" ".repeat(prefix_width), header_style)); - } - spans.push(Span::styled(wrapped, base_style)); - output.push(Line::from(spans)); - } - } - } - - output -} - -fn render_table_summary_lines( - table: &ParsedTable, - indent: &str, - available_width: usize, - base_style: Style, -) -> Vec> { - let mut output = Vec::new(); - let indent_width = UnicodeWidthStr::width(indent); - let summary_width = available_width.saturating_sub(indent_width).max(1); - - let column_list = if table.headers.is_empty() { - String::from("No columns") - } else { - table.headers.join(", ") - }; - - let row_count = table.rows.len(); - let prefix = if row_count == 0 { - "Table:".to_string() - } else if row_count == 1 { - "Table (1 row):".to_string() - } else { - format!("Table ({} rows):", row_count) - }; - - let prefix_width = UnicodeWidthStr::width(prefix.as_str()); - if summary_width <= prefix_width + 1 { - let mut combined = prefix.clone(); - if !column_list.is_empty() { - combined.push(' '); - combined.push_str(&column_list); - } - let wrapped = { - let mut result = wrap_unicode(combined.as_str(), summary_width); - if result.is_empty() { - result.push(String::new()); - } - result - }; - for (index, text) in wrapped.into_iter().enumerate() { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), base_style)); - } - let style = if index == 0 { - base_style.add_modifier(Modifier::BOLD) - } else { - base_style - }; - spans.push(Span::styled(text, style)); - output.push(Line::from(spans)); - } - return output; - } - - let rest_width = summary_width.saturating_sub(prefix_width + 1).max(1); - let mut rest_lines = if column_list.is_empty() { - vec![String::new()] - } else { - let mut wrapped = wrap_unicode(column_list.as_str(), rest_width); - if wrapped.is_empty() { - wrapped.push(String::new()); - } - wrapped - }; - - for (index, text) in rest_lines.drain(..).enumerate() { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), base_style)); - } - if index == 0 { - spans.push(Span::styled( - format!("{prefix} "), - base_style.add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled(text, base_style)); - } else { - spans.push(Span::styled(" ".repeat(prefix_width + 1), base_style)); - spans.push(Span::styled(text, base_style)); - } - output.push(Line::from(spans)); - } - - output -} - -fn build_table_border_line( - left: char, - mid: char, - right: char, - column_widths: &[usize], - indent: &str, - base_style: Style, -) -> Line<'static> { - let mut line = String::new(); - line.push(left); - for (index, width) in column_widths.iter().enumerate() { - line.push_str(&"─".repeat(width + 2)); - if index == column_widths.len() - 1 { - line.push(right); - } else { - line.push(mid); - } - } - - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), base_style)); - } - spans.push(Span::styled(line, base_style)); - Line::from(spans) -} - -fn render_table_row_lines( - cells: &[String], - column_widths: &[usize], - alignments: &[TableAlignment], - indent: &str, - border_style: Style, - cell_styles: &[Style], -) -> Vec> { - let column_count = column_widths.len(); - let mut column_lines: Vec> = Vec::with_capacity(column_count); - - for (index, width) in column_widths.iter().enumerate() { - let cell = cells.get(index).map(String::as_str).unwrap_or(""); - column_lines.push(wrap_table_cell(cell, (*width).max(1))); - } - - let max_height = column_lines - .iter() - .map(|lines| lines.len()) - .max() - .unwrap_or(1); - - let mut output = Vec::with_capacity(max_height); - for line_index in 0..max_height { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), border_style)); - } - spans.push(Span::styled("│".to_string(), border_style)); - - for (column_index, width) in column_widths.iter().enumerate() { - let content = column_lines - .get(column_index) - .and_then(|lines| lines.get(line_index)) - .map(|s| s.as_str()) - .unwrap_or(""); - let alignment = alignments - .get(column_index) - .copied() - .unwrap_or(TableAlignment::Left); - let aligned = align_cell_line(alignment, content, *width); - let cell_style = cell_styles - .get(column_index) - .copied() - .unwrap_or(border_style); - - spans.push(Span::styled(" ".to_string(), border_style)); - spans.push(Span::styled(aligned, cell_style)); - spans.push(Span::styled(" ".to_string(), border_style)); - spans.push(Span::styled("│".to_string(), border_style)); - } - - output.push(Line::from(spans)); - } - - output -} - -fn align_cell_line(alignment: TableAlignment, content: &str, width: usize) -> String { - let display_width = UnicodeWidthStr::width(content); - if display_width >= width { - return content.to_string(); - } - - let padding = width - display_width; - match alignment { - TableAlignment::Left => format!("{content}{}", " ".repeat(padding)), - TableAlignment::Right => format!("{}{}", " ".repeat(padding), content), - TableAlignment::Center => { - let left = padding / 2; - let right = padding - left; - format!("{}{}{}", " ".repeat(left), content, " ".repeat(right)) - } - } -} - -fn wrap_table_cell(content: &str, width: usize) -> Vec { - if width == 0 { - return vec![String::new()]; - } - - let mut lines = Vec::new(); - for segment in content.split('\n') { - let trimmed = segment.trim_end(); - if trimmed.is_empty() { - lines.push(String::new()); - continue; - } - - let options = Options::new(width) - .word_separator(WordSeparator::UnicodeBreakProperties) - .break_words(true); - let wrapped = wrap(trimmed, options); - - if wrapped.is_empty() { - lines.push(String::new()); - } else { - lines.extend(wrapped.into_iter().map(|line| line.into_owned())); - } - } - - if lines.is_empty() { - lines.push(String::new()); - } - - lines -} - -fn constrain_column_widths( - desired: &[usize], - available: usize, - min_width: usize, -) -> Option> { - if desired.is_empty() { - return Some(Vec::new()); - } - if available < min_width.saturating_mul(desired.len()) { - return None; - } - - let mut widths: Vec = desired - .iter() - .map(|value| (*value).max(min_width)) - .collect(); - let mut total: usize = widths.iter().sum(); - - if total <= available { - return Some(widths); - } - - while total > available { - let mut changed = false; - for value in &mut widths { - if total <= available { - break; - } - if *value > min_width { - *value -= 1; - total -= 1; - changed = true; - } - } - - if !changed { - break; - } - } - - if total > available { - None - } else { - Some(widths) - } -} - -fn cell_display_width(value: &str) -> usize { - value - .split('\n') - .map(|segment| UnicodeWidthStr::width(segment.trim_end())) - .max() - .unwrap_or(0) - .max(1) -} - -fn blank_line(indent: &str, base_style: Style) -> Line<'static> { - let mut spans = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), base_style)); - } - spans.push(Span::styled(String::new(), base_style)); - Line::from(spans) -} - -fn wrap_markdown_spans( - spans: Vec>, - indent: &str, - available_width: usize, - base_style: Style, -) -> Vec> { - let width = available_width.max(1); - if spans.is_empty() { - let mut line_spans = Vec::new(); - if !indent.is_empty() { - line_spans.push(Span::styled(indent.to_string(), base_style)); - } - line_spans.push(Span::styled(String::new(), base_style)); - return vec![Line::from(line_spans)]; - } - - let mut result: Vec> = Vec::new(); - let mut current: Vec> = Vec::new(); - let mut remaining = width; - if !indent.is_empty() { - current.push(Span::styled(indent.to_string(), base_style)); - remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); - } - - for span in spans { - let mut content = span.content.into_owned(); - let style = span.style; - if content.is_empty() { - continue; - } - - while !content.is_empty() { - if remaining == 0 { - result.push(Line::from(std::mem::take(&mut current))); - if !indent.is_empty() { - current.push(Span::styled(indent.to_string(), base_style)); - } - remaining = width; - if !indent.is_empty() { - remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); - } - } - - let available = remaining; - let mut take_bytes = 0; - let mut take_width = 0; - - for grapheme in content.graphemes(true) { - let grapheme_width = UnicodeWidthStr::width(grapheme); - if take_width + grapheme_width > available { - break; - } - take_bytes += grapheme.len(); - take_width += grapheme_width; - if take_width == available { - break; - } - } - - if take_bytes == 0 { - result.push(Line::from(std::mem::take(&mut current))); - if !indent.is_empty() { - current.push(Span::styled(indent.to_string(), base_style)); - } - remaining = width; - if !indent.is_empty() { - remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); - } - continue; - } - - let chunk = content[..take_bytes].to_string(); - content = content[take_bytes..].to_string(); - current.push(Span::styled(chunk, style)); - remaining = remaining.saturating_sub(take_width); - } - } - - if current.is_empty() { - if !indent.is_empty() { - current.push(Span::styled(indent.to_string(), base_style)); - } - current.push(Span::styled(String::new(), base_style)); - } - - result.push(Line::from(current)); - result -} - -fn wrap_highlight_segments( - segments: Vec<(Style, String)>, - code_width: usize, - theme: &Theme, -) -> Vec> { - let mut rows: Vec> = Vec::new(); - let mut current: Vec<(Style, String)> = Vec::new(); - let mut current_width: usize = 0; - - let push_row = |rows: &mut Vec>, - current: &mut Vec<(Style, String)>, - current_width: &mut usize| { - rows.push(std::mem::take(current)); - *current_width = 0; - }; - - for (style_raw, text) in segments { - let mut remaining = text.as_str(); - if remaining.is_empty() { - continue; - } - - while !remaining.is_empty() { - if current_width >= code_width { - push_row(&mut rows, &mut current, &mut current_width); - } - - let available = code_width.saturating_sub(current_width); - if available == 0 { - push_row(&mut rows, &mut current, &mut current_width); - continue; - } - - let mut take_bytes = 0; - let mut take_width = 0; - - for grapheme in remaining.graphemes(true) { - let grapheme_width = UnicodeWidthStr::width(grapheme); - if take_width + grapheme_width > available { - break; - } - take_bytes += grapheme.len(); - take_width += grapheme_width; - if take_width == available { - break; - } - } - - if take_bytes == 0 { - push_row(&mut rows, &mut current, &mut current_width); - continue; - } - - let chunk = &remaining[..take_bytes]; - remaining = &remaining[take_bytes..]; - - let mut style = style_raw; - if style.fg.is_none() { - style = style.fg(crate::color_convert::to_ratatui_color( - &theme.code_block_text, - )); - } - style = style.bg(crate::color_convert::to_ratatui_color( - &theme.code_block_background, - )); - - current.push((style, chunk.to_string())); - current_width += take_width; - } - } - - if !current.is_empty() { - rows.push(current); - } else if rows.is_empty() { - rows.push(Vec::new()); - } - - rows -} - -fn inline_code_spans_from_text(text: &str, theme: &Theme, base_style: Style) -> Vec> { - let tick_count = text.matches('`').count(); - if tick_count < 2 || (tick_count & 1) != 0 { - return vec![Span::styled(text.to_string(), base_style)]; - } - - let code_style = Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.code_block_text, - )) - .bg(crate::color_convert::to_ratatui_color( - &theme.code_block_background, - )) - .add_modifier(Modifier::BOLD); - - let mut spans = Vec::new(); - let mut buffer = String::new(); - let mut in_code = false; - - for ch in text.chars() { - if ch == '`' { - if in_code { - if !buffer.is_empty() { - spans.push(Span::styled(buffer.clone(), code_style)); - buffer.clear(); - } - } else if !buffer.is_empty() { - spans.push(Span::styled(buffer.clone(), base_style)); - buffer.clear(); - } - in_code = !in_code; - } else { - buffer.push(ch); - } - } - - if in_code { - return vec![Span::styled(text.to_string(), base_style)]; - } - - if !buffer.is_empty() { - spans.push(Span::styled(buffer, base_style)); - } - - if spans.is_empty() { - spans.push(Span::styled(String::new(), base_style)); - } - - spans -} - -#[allow(clippy::too_many_arguments)] -fn append_code_block_lines( - rendered: &mut Vec>, - indent: &str, - body_width: usize, - language: Option<&str>, - code_lines: &[String], - theme: &Theme, - syntax_highlighting: bool, - indicator_target: &mut Option, -) { - let body_width = body_width.max(4); - let inner_width = body_width.saturating_sub(2); - let code_width = inner_width.max(1); - - let border_style = Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.code_block_border, - )) - .bg(crate::color_convert::to_ratatui_color( - &theme.code_block_background, - )); - let label_style = Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.code_block_text, - )) - .bg(crate::color_convert::to_ratatui_color( - &theme.code_block_background, - )) - .add_modifier(Modifier::BOLD); - let text_style = Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.code_block_text, - )) - .bg(crate::color_convert::to_ratatui_color( - &theme.code_block_background, - )); - - let mut top_spans = Vec::new(); - top_spans.push(Span::styled(indent.to_string(), border_style)); - top_spans.push(Span::styled("╭", border_style)); - - let language_label = language - .and_then(|lang| { - let trimmed = lang.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - }) - .map(|label| format!(" {} ", label)); - - if inner_width > 0 { - if let Some(label) = language_label { - let label_width = UnicodeWidthStr::width(label.as_str()); - if label_width < inner_width { - let left = (inner_width - label_width) / 2; - let right = inner_width - label_width - left; - if left > 0 { - top_spans.push(Span::styled("─".repeat(left), border_style)); - } - top_spans.push(Span::styled(label, label_style)); - if right > 0 { - top_spans.push(Span::styled("─".repeat(right), border_style)); - } - } else { - top_spans.push(Span::styled("─".repeat(inner_width), border_style)); - } - } else { - top_spans.push(Span::styled("─".repeat(inner_width), border_style)); - } - } - - top_spans.push(Span::styled("╮", border_style)); - rendered.push(Line::from(top_spans)); - - let mut highlighter = if syntax_highlighting { - Some(highlight::build_highlighter_for_language(language)) - } else { - None - }; - - let mut process_line = |line: &str| { - let segments = if let Some(highlighter) = highlighter.as_mut() { - let mut segments = highlight::highlight_line(highlighter, line); - if segments.is_empty() { - segments.push((Style::default(), String::new())); - } - segments - } else { - vec![(Style::default(), line.to_string())] - }; - - let has_content = segments.iter().any(|(_, text)| !text.is_empty()); - let rows = if has_content { - wrap_highlight_segments(segments, code_width, theme) - } else { - vec![Vec::new()] - }; - - for row in rows { - let mut spans = Vec::new(); - spans.push(Span::styled(indent.to_string(), border_style)); - spans.push(Span::styled("│", border_style)); - - let mut row_width = 0; - if row.is_empty() { - spans.push(Span::styled(" ".repeat(code_width), text_style)); - } else { - for (style, piece) in row { - let width = UnicodeWidthStr::width(piece.as_str()); - row_width += width; - spans.push(Span::styled(piece, style)); - } - if row_width < code_width { - spans.push(Span::styled(" ".repeat(code_width - row_width), text_style)); - } - } - - spans.push(Span::styled("│", border_style)); - rendered.push(Line::from(spans)); - *indicator_target = Some(rendered.len() - 1); - } - }; - - if code_lines.is_empty() { - process_line(""); - } else { - for line in code_lines { - process_line(line); - } - } - - let mut bottom_spans = Vec::new(); - bottom_spans.push(Span::styled(indent.to_string(), border_style)); - bottom_spans.push(Span::styled("╰", border_style)); - if inner_width > 0 { - bottom_spans.push(Span::styled("─".repeat(inner_width), border_style)); - } - bottom_spans.push(Span::styled("╯", border_style)); - rendered.push(Line::from(bottom_spans)); -} - -fn append_code_block_lines_plain( - output: &mut Vec, - indent: &str, - body_width: usize, - language: Option<&str>, - code_lines: &[String], - indicator_target: &mut Option, -) { - let body_width = body_width.max(4); - let inner_width = body_width.saturating_sub(2); - let code_width = inner_width.max(1); - - let mut top_line = String::new(); - top_line.push_str(indent); - top_line.push('╭'); - - if inner_width > 0 { - if let Some(label) = language.and_then(|lang| { - let trimmed = lang.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - }) { - let label_text = format!(" {} ", label); - let label_width = UnicodeWidthStr::width(label_text.as_str()); - if label_width < inner_width { - let left = (inner_width - label_width) / 2; - let right = inner_width - label_width - left; - top_line.push_str(&"─".repeat(left)); - top_line.push_str(&label_text); - top_line.push_str(&"─".repeat(right)); - } else { - top_line.push_str(&"─".repeat(inner_width)); - } - } else { - top_line.push_str(&"─".repeat(inner_width)); - } - } - - top_line.push('╮'); - output.push(top_line); - - if code_lines.is_empty() { - let chunks = wrap_code("", code_width); - for chunk in chunks { - let mut line = String::new(); - line.push_str(indent); - line.push('│'); - line.push_str(&chunk); - let display_width = UnicodeWidthStr::width(chunk.as_str()); - if display_width < code_width { - line.push_str(&" ".repeat(code_width - display_width)); - } - line.push('│'); - output.push(line); - *indicator_target = Some(output.len() - 1); - } - } else { - for line_text in code_lines { - let chunks = wrap_code(line_text.as_str(), code_width); - for chunk in chunks { - let mut line = String::new(); - line.push_str(indent); - line.push('│'); - line.push_str(&chunk); - let display_width = UnicodeWidthStr::width(chunk.as_str()); - if display_width < code_width { - line.push_str(&" ".repeat(code_width - display_width)); - } - line.push('│'); - output.push(line); - *indicator_target = Some(output.len() - 1); - } - } - } - - let mut bottom_line = String::new(); - bottom_line.push_str(indent); - bottom_line.push('╰'); - if inner_width > 0 { - bottom_line.push_str(&"─".repeat(inner_width)); - } - bottom_line.push('╯'); - output.push(bottom_line); -} - -pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec { - if width == 0 { - return Vec::new(); - } - - let options = Options::new(width) - .word_separator(WordSeparator::UnicodeBreakProperties) - .break_words(false); - - wrap(text, options) - .into_iter() - .map(|segment| segment.into_owned()) - .collect() -} - -#[derive(Debug, Clone)] -struct CloudSetupOptions { - provider: String, - endpoint: Option, - api_key: Option, - force_cloud_base_url: bool, -} - -impl CloudSetupOptions { - fn parse(args: &[&str]) -> Result { - let mut options = CloudSetupOptions { - provider: "ollama_cloud".to_string(), - endpoint: None, - api_key: None, - force_cloud_base_url: false, - }; - - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - match arg.trim() { - "--provider" => { - let value = iter.next().ok_or_else(|| { - anyhow!("--provider expects a value (e.g. --provider ollama)") - })?; - options.provider = canonical_provider_name(value); - } - "--endpoint" => { - let value = iter.next().ok_or_else(|| { - anyhow!("--endpoint expects a URL (e.g. --endpoint https://ollama.com)") - })?; - options.endpoint = Some(value.trim().to_string()); - } - "--api-key" => { - let value = iter.next().ok_or_else(|| { - anyhow!("--api-key expects a value (e.g. --api-key sk-...)") - })?; - options.api_key = Some(value.trim().to_string()); - } - "--force-cloud-base-url" => { - options.force_cloud_base_url = true; - } - flag if flag.starts_with("--") => { - return Err(anyhow!("Unknown flag '{flag}' for :cloud setup")); - } - value => { - if options.api_key.is_none() { - options.api_key = Some(value.trim().to_string()); - } else { - return Err(anyhow!( - "Unexpected argument '{value}'. Provide a single API key or use --api-key." - )); - } - } - } - } - - if options.provider.trim().is_empty() { - options.provider = "ollama_cloud".to_string(); - } - - options.provider = canonical_provider_name(&options.provider); - - Ok(options) - } -} - -fn canonical_provider_name(provider: &str) -> String { - let normalized = provider.trim().to_ascii_lowercase().replace('-', "_"); - match normalized.as_str() { - "" => "ollama_cloud".to_string(), - "ollama" => "ollama_cloud".to_string(), - "ollama_cloud" => "ollama_cloud".to_string(), - value => value.to_string(), - } -} - -fn normalize_cloud_endpoint(endpoint: &str) -> String { - let trimmed = endpoint.trim().trim_end_matches('/'); - if trimmed.is_empty() { - DEFAULT_CLOUD_ENDPOINT.to_string() - } else { - trimmed.to_string() - } -} - -#[cfg(test)] -#[allow(clippy::items_after_test_module)] -mod tests { - use super::{ChatApp, ModelAvailabilityState, ModelScope, render_markdown_lines, wrap_unicode}; - use crate::app::UiRuntime; - use futures_util::{future, stream}; - use owlen_core::{ - Provider, Result as CoreResult, - config::Config, - consent::ConsentScope, - llm::LlmProvider, - session::{ControllerEvent, SessionController}, - storage::StorageManager, - types::{ChatRequest, ChatResponse, Message, ModelInfo, Role, ToolCall}, - ui::NoOpUiController, - }; - use ratatui::style::Style; - use ratatui::text::Line; - use serde_json::json; - use std::sync::Arc; - use tempfile::tempdir; - use tokio::sync::mpsc; - - fn lines_to_strings(lines: &[Line<'_>]) -> Vec { - lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect() - } - - #[test] - fn render_markdown_table_draws_grid_when_width_allows() { - let lines = render_markdown_lines( - "| Name | Role |\n| --- | --- |\n| Alice | Developer |\n", - "", - 60, - Style::default(), - ); - let rendered = lines_to_strings(&lines); - assert!( - rendered.iter().any(|line| line.contains("Table (1 row):")), - "summary line should mention row count" - ); - assert!( - rendered.iter().any(|line| line.contains('┌')), - "grid border should be present when width permits" - ); - assert!( - rendered.iter().any(|line| line.contains("Alice")), - "table rows should include cell content" - ); - } - - #[test] - fn render_markdown_table_falls_back_when_narrow() { - let lines = render_markdown_lines( - "| Name | Role |\n| --- | --- |\n| Alice | Developer |\n", - "", - 12, - Style::default(), - ); - let rendered = lines_to_strings(&lines); - assert!( - rendered.iter().any(|line| line.contains("Name:")), - "stacked fallback should label headers inline" - ); - assert!( - rendered.iter().all(|line| !line.contains('┌')), - "narrow layout should avoid grid borders" - ); - } - - #[test] - fn wrap_unicode_respects_cjk_display_width() { - let wrapped = wrap_unicode("你好世界", 4); - assert_eq!(wrapped, vec!["你好".to_string(), "世界".to_string()]); - } - - #[test] - fn wrap_unicode_handles_emoji_graphemes() { - let wrapped = wrap_unicode("🙂🙂🙂", 4); - assert_eq!(wrapped, vec!["🙂🙂".to_string(), "🙂".to_string()]); - } - - #[test] - fn wrap_unicode_zero_width_returns_empty() { - let wrapped = wrap_unicode("hello", 0); - assert!(wrapped.is_empty()); - } - - #[test] - fn extract_scope_status_includes_extended_metadata() { - let models = vec![ModelInfo { - id: "demo".to_string(), - name: "Demo".to_string(), - description: None, - provider: "demo".to_string(), - context_window: None, - capabilities: vec![ - "scope:local".to_string(), - "scope-status:local:available".to_string(), - "scope-status-age:local:30".to_string(), - "scope-status-success-age:local:5".to_string(), - "scope-status-stale:local:1".to_string(), - "scope-status-message:local:Cached copy".to_string(), - ], - supports_tools: false, - }]; - - let statuses = ChatApp::extract_scope_status(&models); - let entry = statuses.get(&ModelScope::Local).expect("local scope entry"); - - assert_eq!(entry.state, ModelAvailabilityState::Available); - assert_eq!(entry.last_checked_secs, Some(30)); - assert_eq!(entry.last_success_secs, Some(5)); - assert!(entry.is_stale); - assert_eq!(entry.message.as_deref(), Some("Cached copy")); - } - - struct StubProvider; - - impl LlmProvider for StubProvider { - type Stream = stream::Iter>>; - - type ListModelsFuture<'a> - = future::Ready>> - where - Self: 'a; - - type SendPromptFuture<'a> - = future::Ready> - where - Self: 'a; - - type StreamPromptFuture<'a> - = future::Ready> - where - Self: 'a; - - type HealthCheckFuture<'a> - = future::Ready> - where - Self: 'a; - - fn name(&self) -> &str { - "stub-provider" - } - - fn list_models(&self) -> Self::ListModelsFuture<'_> { - future::ready(Ok(vec![])) - } - - fn send_prompt(&self, _request: ChatRequest) -> Self::SendPromptFuture<'_> { - let response = ChatResponse { - message: Message::assistant("stub response".to_string()), - usage: None, - is_streaming: false, - is_final: true, - }; - future::ready(Ok(response)) - } - - fn stream_prompt(&self, _request: ChatRequest) -> Self::StreamPromptFuture<'_> { - let response = ChatResponse { - message: Message::assistant("stub response".to_string()), - usage: None, - is_streaming: false, - is_final: true, - }; - future::ready(Ok(stream::iter(vec![Ok(response)]))) - } - - fn health_check(&self) -> Self::HealthCheckFuture<'_> { - future::ready(Ok(())) - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_consent_denied_generates_fallback_message() { - let temp_dir = tempdir().expect("tempdir"); - let storage_path = temp_dir.path().join("owlen-test.db"); - let storage = Arc::new( - StorageManager::with_database_path(storage_path) - .await - .expect("storage"), - ); - - let mut config = Config::default(); - config.privacy.encrypt_local_data = false; - let provider: Arc = Arc::new(StubProvider); - let ui = Arc::new(NoOpUiController); - let (event_tx, controller_event_rx) = mpsc::unbounded_channel::(); - - let session = SessionController::new(provider, config, storage, ui, false, Some(event_tx)) - .await - .expect("session"); - - let (mut app, session_rx) = ChatApp::new(session, controller_event_rx) - .await - .expect("chat app"); - // Session events are not needed for this test - drop(session_rx); - - let tool_call = ToolCall { - id: "call-1".to_string(), - name: "file_delete".to_string(), - arguments: json!({"path": "/tmp/example.txt"}), - }; - - let message_id = app - .controller - .lock() - .await - .conversation_mut() - .push_assistant_message("Preparing to modify files."); - app.controller - .lock() - .await - .conversation_mut() - .set_tool_calls_on_message(message_id, vec![tool_call.clone()]) - .expect("tool calls"); - - app.pending_tool_execution = Some((message_id, vec![tool_call.clone()])); - - // Trigger the consent check flow by calling check_streaming_tool_calls - // This properly registers the request and sends the event - { - let mut controller_guard = app.controller.lock().await; - controller_guard.check_streaming_tool_calls(message_id); - drop(controller_guard); - } - - UiRuntime::poll_controller_events(&mut app).expect("poll controller events"); - - let consent_state = app - .pending_consent - .as_ref() - .expect("pending consent") - .clone(); - - assert_eq!(consent_state.tool_name, "file_delete"); - - let resolution = app - .controller - .lock() - .await - .resolve_tool_consent(consent_state.request_id, ConsentScope::Denied) - .expect("resolution"); - - app.apply_tool_consent_resolution(resolution) - .expect("apply resolution"); - - assert!(app.pending_consent.is_none()); - assert!(app.pending_tool_execution.is_none()); - assert!(app.status.to_lowercase().contains("consent denied")); - - let controller_guard = app.controller.lock().await; - let conversation = controller_guard.conversation(); - let last_message = conversation.messages.last().expect("last message"); - assert_eq!(last_message.role, Role::Assistant); - assert!( - last_message.content.contains("consent was denied"), - "fallback message should acknowledge denial" - ); - } -} - -fn validate_relative_path(path: &Path, allow_nested: bool) -> Result<()> { - if path.as_os_str().is_empty() { - return Err(anyhow!("Path cannot be empty")); - } - if path.is_absolute() { - return Err(anyhow!("Path must be relative to the workspace root")); - } - - let mut normal_segments = 0usize; - for component in path.components() { - match component { - Component::Normal(_) => { - normal_segments += 1; - } - Component::CurDir => { - return Err(anyhow!("Path cannot contain '.' segments")); - } - Component::ParentDir => { - return Err(anyhow!("Path cannot contain '..' segments")); - } - Component::RootDir | Component::Prefix(_) => { - return Err(anyhow!("Path must be relative to the workspace root")); - } - } - } - - if !allow_nested && normal_segments > 1 { - return Err(anyhow!("Name cannot include path separators")); - } - - Ok(()) -} - -fn configure_textarea_defaults(textarea: &mut TextArea<'static>) { - textarea.set_placeholder_text("Type your message here..."); - textarea.set_tab_length(4); - - textarea.set_style( - Style::default() - .remove_modifier(Modifier::UNDERLINED) - .remove_modifier(Modifier::ITALIC) - .remove_modifier(Modifier::BOLD), - ); - textarea.set_cursor_style(Style::default()); - textarea.set_cursor_line_style(Style::default()); -} - -impl MessageState for ChatApp {} - -#[async_trait] -impl UiRuntime for ChatApp { - async fn handle_ui_event(&mut self, event: Event) -> Result { - ChatApp::handle_event(self, event).await - } - - async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> { - ChatApp::handle_session_event(self, event).await - } - - async fn process_pending_llm_request(&mut self) -> Result<()> { - ChatApp::process_pending_llm_request(self).await - } - - async fn process_pending_tool_execution(&mut self) -> Result<()> { - ChatApp::process_pending_tool_execution(self).await - } - - fn poll_controller_events(&mut self) -> Result<()> { - loop { - match self.controller_event_rx.try_recv() { - Ok(event) => self.handle_controller_event(event)?, - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, - } - } - Ok(()) - } - - fn advance_loading_animation(&mut self) { - ChatApp::advance_loading_animation(self); - } - - fn streaming_count(&self) -> usize { - ChatApp::streaming_count(self) - } -} - -#[cfg(test)] -mod queue_tests { - use super::ChatApp; - - #[test] - fn summarize_thinking_uses_first_line() { - let text = "Outline the plan.\nThen execute."; - let summary = ChatApp::summarize_thinking(text).expect("summary"); - assert!( - summary.starts_with("Outline the plan"), - "summary should start with first line" - ); - } - - #[test] - fn summarize_thinking_truncates_long_text() { - let long_segment = "a".repeat(200); - let text = format!("{long_segment}\nNext steps"); - let summary = ChatApp::summarize_thinking(&text).expect("summary"); - assert!( - summary.chars().count() <= 160, - "summary should be limited to 160 characters" - ); - assert!( - summary.ends_with('…'), - "summary should end with ellipsis when truncated" - ); - } -} diff --git a/crates/owlen-tui/src/code_app.rs b/crates/owlen-tui/src/code_app.rs deleted file mode 100644 index ebfac72..0000000 --- a/crates/owlen-tui/src/code_app.rs +++ /dev/null @@ -1,47 +0,0 @@ -use anyhow::Result; -use owlen_core::session::{ControllerEvent, SessionController}; -use owlen_core::ui::{AppState, InputMode}; -use tokio::sync::mpsc; - -use crate::chat_app::{ChatApp, SessionEvent}; -use crate::events::Event; - -const DEFAULT_SYSTEM_PROMPT: &str = - "You are OWLEN Code Assistant. Provide detailed, actionable programming help."; - -pub struct CodeApp { - inner: ChatApp, -} - -impl CodeApp { - pub async fn new( - mut controller: SessionController, - controller_event_rx: mpsc::UnboundedReceiver, - ) -> Result<(Self, mpsc::UnboundedReceiver)> { - controller - .conversation_mut() - .push_system_message(DEFAULT_SYSTEM_PROMPT.to_string()); - let (inner, rx) = ChatApp::new(controller, controller_event_rx).await?; - Ok((Self { inner }, rx)) - } - - pub async fn handle_event(&mut self, event: Event) -> Result { - self.inner.handle_event(event).await - } - - pub async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> { - self.inner.handle_session_event(event).await - } - - pub fn mode(&self) -> InputMode { - self.inner.mode() - } - - pub fn inner(&self) -> &ChatApp { - &self.inner - } - - pub fn inner_mut(&mut self) -> &mut ChatApp { - &mut self.inner - } -} diff --git a/crates/owlen-tui/src/color_convert.rs b/crates/owlen-tui/src/color_convert.rs deleted file mode 100644 index b55fccb..0000000 --- a/crates/owlen-tui/src/color_convert.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Color conversion utilities for mapping owlen-ui-common colors to ratatui colors - -use owlen_core::{Color as UiColor, NamedColor}; -use ratatui::style::Color as RatatuiColor; - -/// Convert an abstract UI color to a ratatui color -pub fn to_ratatui_color(color: &UiColor) -> RatatuiColor { - match color { - UiColor::Rgb(r, g, b) => RatatuiColor::Rgb(*r, *g, *b), - UiColor::Named(named) => to_ratatui_named_color(named), - } -} - -/// Convert a named color to a ratatui color -fn to_ratatui_named_color(named: &NamedColor) -> RatatuiColor { - match named { - NamedColor::Black => RatatuiColor::Black, - NamedColor::Red => RatatuiColor::Red, - NamedColor::Green => RatatuiColor::Green, - NamedColor::Yellow => RatatuiColor::Yellow, - NamedColor::Blue => RatatuiColor::Blue, - NamedColor::Magenta => RatatuiColor::Magenta, - NamedColor::Cyan => RatatuiColor::Cyan, - NamedColor::Gray => RatatuiColor::Gray, - NamedColor::DarkGray => RatatuiColor::DarkGray, - NamedColor::LightRed => RatatuiColor::LightRed, - NamedColor::LightGreen => RatatuiColor::LightGreen, - NamedColor::LightYellow => RatatuiColor::LightYellow, - NamedColor::LightBlue => RatatuiColor::LightBlue, - NamedColor::LightMagenta => RatatuiColor::LightMagenta, - NamedColor::LightCyan => RatatuiColor::LightCyan, - NamedColor::White => RatatuiColor::White, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_rgb_conversion() { - let color = UiColor::Rgb(255, 128, 64); - let ratatui_color = to_ratatui_color(&color); - assert_eq!(ratatui_color, RatatuiColor::Rgb(255, 128, 64)); - } - - #[test] - fn test_named_color_conversion() { - let color = UiColor::Named(NamedColor::Red); - let ratatui_color = to_ratatui_color(&color); - assert_eq!(ratatui_color, RatatuiColor::Red); - - let color = UiColor::Named(NamedColor::LightBlue); - let ratatui_color = to_ratatui_color(&color); - assert_eq!(ratatui_color, RatatuiColor::LightBlue); - } -} diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs deleted file mode 100644 index f4fc51a..0000000 --- a/crates/owlen-tui/src/commands/mod.rs +++ /dev/null @@ -1,974 +0,0 @@ -pub mod registry; -pub use registry::{AppCommand, CommandRegistry}; - -use std::cmp::Ordering; - -/// High-level category used to group and filter commands. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CommandCategory { - Session, - Conversation, - Workspace, - Navigation, - Layout, - Models, - Providers, - Tools, - Agent, - Accessibility, - Appearance, - System, - Diagnostics, - Support, -} - -impl CommandCategory { - pub fn label(self) -> &'static str { - match self { - CommandCategory::Session => "Sessions", - CommandCategory::Conversation => "Conversation", - CommandCategory::Workspace => "Workspace", - CommandCategory::Navigation => "Navigation", - CommandCategory::Layout => "Layout", - CommandCategory::Models => "Models", - CommandCategory::Providers => "Providers", - CommandCategory::Tools => "Tools & Integrations", - CommandCategory::Agent => "Agent", - CommandCategory::Accessibility => "Accessibility", - CommandCategory::Appearance => "Appearance", - CommandCategory::System => "System", - CommandCategory::Diagnostics => "Diagnostics", - CommandCategory::Support => "Support", - } - } -} - -/// Structured preview content rendered alongside the palette. -#[derive(Debug, Clone, Copy)] -pub struct CommandPreview { - pub title: &'static str, - pub body: &'static [&'static str], -} - -/// Rich metadata describing a single command keyword (and optional aliases). -#[derive(Debug, Clone, Copy)] -pub struct CommandDescriptor { - pub keywords: &'static [&'static str], - pub description: &'static str, - pub category: CommandCategory, - pub modes: &'static [&'static str], - pub tags: &'static [&'static str], - pub keybinding: Option<&'static str>, - pub preview: Option<&'static CommandPreview>, -} - -impl CommandDescriptor { - pub fn keywords(&self) -> &[&'static str] { - self.keywords - } - - pub fn primary_keyword(&self) -> &'static str { - self.keywords[0] - } -} - -/// Result returned by [`search`], including the concrete keyword that matched. -#[derive(Debug, Clone, Copy)] -pub struct CommandHit { - pub keyword: &'static str, - pub descriptor: &'static CommandDescriptor, - score: CommandScore, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct CommandScore { - priority_sum: usize, - primary_sum: usize, - secondary_sum: usize, - alias_index: usize, - keyword_len: usize, - order: usize, -} - -impl Ord for CommandScore { - fn cmp(&self, other: &Self) -> Ordering { - self.priority_sum - .cmp(&other.priority_sum) - .then(self.primary_sum.cmp(&other.primary_sum)) - .then(self.secondary_sum.cmp(&other.secondary_sum)) - .then(self.alias_index.cmp(&other.alias_index)) - .then(self.keyword_len.cmp(&other.keyword_len)) - .then(self.order.cmp(&other.order)) - } -} - -impl PartialOrd for CommandScore { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -const PREVIEW_FILES: CommandPreview = CommandPreview { - title: "Files Panel", - body: &[ - "Toggle the workspace browser and focus the file tree.", - "Shortcuts: f 1 · Ctrl+1 (Vim) · Alt+1 (Emacs).", - "Use /focus to surface related navigation commands.", - ], -}; - -const PREVIEW_MODEL: CommandPreview = CommandPreview { - title: "Model Picker", - body: &[ - "Browse models with fuzzy search, provider filters, and metadata.", - "Shortcuts: m · m (Normal) · Alt+M (Emacs).", - "Type model to jump directly to a specific model.", - ], -}; - -const PREVIEW_PROVIDER: CommandPreview = CommandPreview { - title: "Provider Switcher", - body: &[ - "Swap between Ollama, OpenAI, Anthropic, or MCP providers.", - "Shortcuts: p · Ctrl+X Ctrl+P (Emacs).", - "Append --cloud or --local to filter available models.", - ], -}; - -const PREVIEW_REPO: CommandPreview = CommandPreview { - title: "Repo automation", - body: &[ - "Generate commit templates or code reviews from the current workspace.", - "Usage: :repo template [--working]", - "Usage: :repo review [--base BRANCH] [--head REF]", - ], -}; - -const COMMANDS: &[CommandDescriptor] = &[ - CommandDescriptor { - keywords: &["quit"], - description: "Exit the application", - category: CommandCategory::System, - modes: &["Command"], - tags: &["system", "exit", "shutdown"], - keybinding: Some("Ctrl+C twice"), - preview: None, - }, - CommandDescriptor { - keywords: &["clear", "c"], - description: "Clear the conversation", - category: CommandCategory::Conversation, - modes: &["Command"], - tags: &["conversation", "reset", "history"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["compress", "compress now", "compress auto"], - description: "Manage transcript compression (run now or toggle auto mode)", - category: CommandCategory::Conversation, - modes: &["Command"], - tags: &["conversation", "compression", "history"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["attach"], - description: "Attach a file to the next user turn", - category: CommandCategory::Conversation, - modes: &["Command"], - tags: &["file", "attachment", "multimodal"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["attachments"], - description: "Manage staged attachments (list, next, clear, remove)", - category: CommandCategory::Conversation, - modes: &["Command"], - tags: &["attachment", "preview", "queue"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["session save"], - description: "Save the current conversation", - category: CommandCategory::Session, - modes: &["Command"], - tags: &["session", "save", "history"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["sessions"], - description: "List saved sessions", - category: CommandCategory::Session, - modes: &["Command"], - tags: &["session", "history", "browse"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["load", "o"], - description: "Load a saved conversation", - category: CommandCategory::Session, - modes: &["Command"], - tags: &["session", "restore", "history"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["new", "n"], - description: "Start a new conversation", - category: CommandCategory::Conversation, - modes: &["Command"], - tags: &["conversation", "reset", "session"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["open"], - description: "Open a file in the code view", - category: CommandCategory::Workspace, - modes: &["Command"], - tags: &["workspace", "files", "edit"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["create"], - description: "Create a file (creates missing directories)", - category: CommandCategory::Workspace, - modes: &["Command"], - tags: &["workspace", "files", "scaffold"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["close", "q"], - description: "Close the active code view", - category: CommandCategory::Workspace, - modes: &["Command"], - tags: &["workspace", "files", "close"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["w", "write", "save"], - description: "Save the active file", - category: CommandCategory::Workspace, - modes: &["Command"], - tags: &["workspace", "files", "save"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["wq", "x"], - description: "Save and close the active file", - category: CommandCategory::Workspace, - modes: &["Command"], - tags: &["workspace", "files", "save", "close"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["ls"], - description: "List directory contents", - category: CommandCategory::Workspace, - modes: &["Command"], - tags: &["workspace", "files", "list"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["edit", "e"], - description: "Edit a file", - category: CommandCategory::Workspace, - modes: &["Command"], - tags: &["workspace", "files", "edit"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["mode"], - description: "Switch operating mode (chat/code)", - category: CommandCategory::System, - modes: &["Command"], - tags: &["mode", "context", "system"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["code"], - description: "Switch to code mode", - category: CommandCategory::System, - modes: &["Command"], - tags: &["mode", "code", "focus"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["chat"], - description: "Switch to chat mode", - category: CommandCategory::System, - modes: &["Command"], - tags: &["mode", "chat", "focus"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["tools"], - description: "List available tools in the current mode", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["tools", "integration", "automation"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["repo template"], - description: "Generate a conventional commit template from staged changes", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["repo", "automation", "commit"], - keybinding: None, - preview: Some(&PREVIEW_REPO), - }, - CommandDescriptor { - keywords: &["repo review"], - description: "Summarise the current branch as a pull request review", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["repo", "automation", "review"], - keybinding: None, - preview: Some(&PREVIEW_REPO), - }, - CommandDescriptor { - keywords: &["help", "h"], - description: "Open the help overlay", - category: CommandCategory::Support, - modes: &["Normal", "Command"], - tags: &["help", "docs", "support"], - keybinding: Some("F1 / ?"), - preview: None, - }, - CommandDescriptor { - keywords: &["tutorial"], - description: "Show keybinding tutorial", - category: CommandCategory::Support, - modes: &["Command"], - tags: &["help", "tutorial", "onboarding"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["files", "explorer"], - description: "Toggle the files panel", - category: CommandCategory::Navigation, - modes: &["Normal", "Command"], - tags: &["focus", "panel", "navigation"], - keybinding: Some(" f 1 · Ctrl+1 / Alt+1"), - preview: Some(&PREVIEW_FILES), - }, - CommandDescriptor { - keywords: &["debug log"], - description: "Toggle the debug log panel", - category: CommandCategory::Diagnostics, - modes: &["Normal", "Command"], - tags: &["debug", "logs", "diagnostics"], - keybinding: Some("F12"), - preview: None, - }, - CommandDescriptor { - keywords: &["layout save"], - description: "Persist the current pane layout", - category: CommandCategory::Layout, - modes: &["Command"], - tags: &["layout", "workspace", "save"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["layout load"], - description: "Restore the last saved pane layout", - category: CommandCategory::Layout, - modes: &["Command"], - tags: &["layout", "workspace", "restore"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["model", "m"], - description: "Select a model", - category: CommandCategory::Models, - modes: &["Normal", "Command"], - tags: &["model", "focus", "selection"], - keybinding: Some(" m · m / Alt+M"), - preview: Some(&PREVIEW_MODEL), - }, - CommandDescriptor { - keywords: &["models --local"], - description: "Open model picker focused on local models", - category: CommandCategory::Models, - modes: &["Command"], - tags: &["model", "local", "selection"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["models --cloud"], - description: "Open model picker focused on cloud models", - category: CommandCategory::Models, - modes: &["Command"], - tags: &["model", "cloud", "selection"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["models --available"], - description: "Open model picker showing available models", - category: CommandCategory::Models, - modes: &["Command"], - tags: &["model", "availability", "selection"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["models info"], - description: "Prefetch detailed information for all models", - category: CommandCategory::Models, - modes: &["Command"], - tags: &["model", "metadata", "prefetch"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["model info", "model details"], - description: "Show detailed information for a model", - category: CommandCategory::Models, - modes: &["Command"], - tags: &["model", "metadata", "panel"], - keybinding: Some("i / r (Model picker)"), - preview: None, - }, - CommandDescriptor { - keywords: &["model refresh"], - description: "Refresh cached model information", - category: CommandCategory::Models, - modes: &["Command"], - tags: &["model", "metadata", "refresh"], - keybinding: Some("r (Model picker)"), - preview: None, - }, - CommandDescriptor { - keywords: &["provider"], - description: "Switch provider or set its mode", - category: CommandCategory::Providers, - modes: &["Normal", "Command"], - tags: &["provider", "focus", "selection"], - keybinding: Some(" p · Ctrl+X Ctrl+P"), - preview: Some(&PREVIEW_PROVIDER), - }, - CommandDescriptor { - keywords: &["cloud setup"], - description: "Configure Ollama Cloud credentials", - category: CommandCategory::Providers, - modes: &["Command"], - tags: &["provider", "cloud", "auth"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["cloud status"], - description: "Check Ollama Cloud connectivity", - category: CommandCategory::Providers, - modes: &["Command"], - tags: &["provider", "cloud", "status"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["cloud models"], - description: "List models available in Ollama Cloud", - category: CommandCategory::Providers, - modes: &["Command"], - tags: &["provider", "cloud", "models"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["cloud logout"], - description: "Remove stored Ollama Cloud credentials", - category: CommandCategory::Providers, - modes: &["Command"], - tags: &["provider", "cloud", "auth"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["theme"], - description: "Switch to a specific theme", - category: CommandCategory::Appearance, - modes: &["Command"], - tags: &["appearance", "theme", "customise"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["themes"], - description: "List available themes", - category: CommandCategory::Appearance, - modes: &["Command"], - tags: &["appearance", "theme", "list"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["reload"], - description: "Reload configuration and themes", - category: CommandCategory::System, - modes: &["Command"], - tags: &["system", "config", "reload"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["markdown"], - description: "Toggle markdown rendering", - category: CommandCategory::Appearance, - modes: &["Command"], - tags: &["appearance", "formatting", "markdown"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["limits"], - description: "Show hourly/weekly usage totals", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["usage", "quota", "analytics"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["web on"], - description: "Enable web search tool exposure", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["tools", "web", "search"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["web off"], - description: "Disable web search tool exposure", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["tools", "web", "search"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["web status"], - description: "Show current web search tool state", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["tools", "web", "status"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["privacy-enable"], - description: "Enable a privacy-sensitive tool", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["privacy", "tools", "security"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["privacy-disable"], - description: "Disable a privacy-sensitive tool", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["privacy", "tools", "security"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["privacy-clear"], - description: "Clear stored secure data", - category: CommandCategory::Tools, - modes: &["Command"], - tags: &["privacy", "tools", "security"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["agent"], - description: "Enable agent mode for autonomous task execution", - category: CommandCategory::Agent, - modes: &["Command"], - tags: &["agent", "automation", "workflow"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["agent start"], - description: "Arm the agent for the next request", - category: CommandCategory::Agent, - modes: &["Command"], - tags: &["agent", "automation", "start"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["agent stop", "stop-agent"], - description: "Stop the running agent", - category: CommandCategory::Agent, - modes: &["Command"], - tags: &["agent", "automation", "stop"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["agent status"], - description: "Show current agent status", - category: CommandCategory::Agent, - modes: &["Command"], - tags: &["agent", "automation", "status"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["accessibility"], - description: "Cycle accessibility presets (default → high contrast → high+reduced)", - category: CommandCategory::Accessibility, - modes: &["Command"], - tags: &["accessibility", "contrast", "preset"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["accessibility status"], - description: "Show high-contrast and reduced chrome settings", - category: CommandCategory::Accessibility, - modes: &["Command"], - tags: &["accessibility", "status", "contrast"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["accessibility high on"], - description: "Enable high-contrast mode", - category: CommandCategory::Accessibility, - modes: &["Command"], - tags: &["accessibility", "contrast", "high"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["accessibility high off"], - description: "Disable high-contrast mode", - category: CommandCategory::Accessibility, - modes: &["Command"], - tags: &["accessibility", "contrast", "high"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["accessibility reduced on"], - description: "Enable reduced chrome mode", - category: CommandCategory::Accessibility, - modes: &["Command"], - tags: &["accessibility", "reduced", "chrome"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["accessibility reduced off"], - description: "Disable reduced chrome mode", - category: CommandCategory::Accessibility, - modes: &["Command"], - tags: &["accessibility", "reduced", "chrome"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["accessibility reset"], - description: "Restore default accessibility settings", - category: CommandCategory::Accessibility, - modes: &["Command"], - tags: &["accessibility", "reset", "defaults"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["keymap"], - description: "Show the active keymap profile", - category: CommandCategory::System, - modes: &["Command"], - tags: &["keymap", "keyboard", "profile"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["keymap vim"], - description: "Switch to Vim-style key bindings", - category: CommandCategory::System, - modes: &["Command"], - tags: &["keymap", "vim", "keyboard"], - keybinding: None, - preview: None, - }, - CommandDescriptor { - keywords: &["keymap emacs"], - description: "Switch to Emacs-style key bindings", - category: CommandCategory::System, - modes: &["Command"], - tags: &["keymap", "emacs", "keyboard"], - keybinding: None, - preview: None, - }, -]; - -/// Expose the static command catalog. -pub fn catalog() -> &'static [CommandDescriptor] { - COMMANDS -} - -/// Search the command catalog using fuzzy matching across keywords, tags, and descriptions. -pub fn search<'a>(terms: &[&'a str], tags: &[&'a str]) -> Vec { - let mut results: Vec = Vec::new(); - - for (order, descriptor) in COMMANDS.iter().enumerate() { - if !matches_tags(descriptor, tags) { - continue; - } - - if terms.is_empty() { - results.push(CommandHit { - keyword: descriptor.primary_keyword(), - descriptor, - score: CommandScore { - priority_sum: 0, - primary_sum: 0, - secondary_sum: 0, - alias_index: 0, - keyword_len: descriptor.primary_keyword().len(), - order, - }, - }); - continue; - } - - for (alias_index, keyword) in descriptor.keywords.iter().enumerate() { - if let Some(score) = compute_score(descriptor, keyword, terms, order, alias_index) { - results.push(CommandHit { - keyword, - descriptor, - score, - }); - } - } - } - - results.sort_by(|a, b| { - let cmp = a.score.cmp(&b.score); - if cmp == Ordering::Equal { - a.keyword.cmp(b.keyword) - } else { - cmp - } - }); - results -} - -fn matches_tags(descriptor: &CommandDescriptor, filters: &[&str]) -> bool { - if filters.is_empty() { - return true; - } - - filters.iter().all(|filter| { - let filter = filter.trim(); - if filter.is_empty() { - return true; - } - - let category_match = descriptor.category.label().eq_ignore_ascii_case(filter); - if category_match { - return true; - } - - descriptor - .tags - .iter() - .any(|tag| tag.eq_ignore_ascii_case(filter)) - }) -} - -fn compute_score( - descriptor: &CommandDescriptor, - keyword: &str, - terms: &[&str], - order: usize, - alias_index: usize, -) -> Option { - let mut priority_sum = 0usize; - let mut primary_sum = 0usize; - let mut secondary_sum = 0usize; - - for term in terms { - let term = term.trim(); - if term.is_empty() { - continue; - } - - let mut best: Option<(usize, usize, usize)> = None; - - let mut consider = |candidate: &str, priority: usize| { - if candidate.is_empty() { - return; - } - if let Some((primary, secondary)) = match_score(candidate, term) { - let candidate_score = (priority, primary, secondary); - best = Some(match best { - Some(current) if current <= candidate_score => current, - _ => candidate_score, - }); - } - }; - - consider(keyword, 0); - for token in keyword - .split(|c: char| c.is_whitespace() || c == '-' || c == '_') - .filter(|token| !token.is_empty()) - { - consider(token, 0); - } - for tag in descriptor.tags { - consider(tag, 1); - } - consider(descriptor.description, 2); - consider(descriptor.category.label(), 3); - if let Some(binding) = descriptor.keybinding { - consider(binding, 4); - } - - let (priority, primary, secondary) = best?; - - priority_sum += priority; - primary_sum += primary; - secondary_sum += secondary; - } - - Some(CommandScore { - priority_sum, - primary_sum, - secondary_sum, - alias_index, - keyword_len: keyword.len(), - order, - }) -} - -/// Compute a fuzzy ranking between a candidate string and a query token. -pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> { - let query = query.trim(); - if query.is_empty() { - return Some((usize::MAX, candidate.len())); - } - - let candidate_normalized = candidate.trim().to_lowercase(); - if candidate_normalized.is_empty() { - return None; - } - - let query_normalized = query.to_lowercase(); - - if candidate_normalized == query_normalized { - Some((0, candidate.len())) - } else if candidate_normalized.starts_with(&query_normalized) { - Some((1, 0)) - } else if let Some(pos) = candidate_normalized.find(&query_normalized) { - Some((2, pos)) - } else if is_subsequence(&candidate_normalized, &query_normalized) { - Some((3, candidate.len())) - } else { - None - } -} - -fn is_subsequence(text: &str, pattern: &str) -> bool { - if pattern.is_empty() { - return true; - } - - let mut pattern_chars = pattern.chars(); - let mut current = match pattern_chars.next() { - Some(ch) => ch, - None => return true, - }; - - for ch in text.chars() { - if ch == current { - match pattern_chars.next() { - Some(next_ch) => current = next_ch, - None => return true, - } - } - } - - false -} - -#[cfg(test)] -mod tests { - use super::*; - - fn lower_terms(input: &str) -> Vec { - input - .split_whitespace() - .map(|s| s.to_ascii_lowercase()) - .collect() - } - - #[test] - fn search_prefers_agent_start() { - let terms_owned = lower_terms("agent st"); - let term_refs: Vec<&str> = terms_owned.iter().map(|s| s.as_str()).collect(); - let hits = search(&term_refs, &[]); - assert!(!hits.is_empty()); - let top_two: Vec<&str> = hits.iter().take(2).map(|hit| hit.keyword).collect(); - assert!(top_two.contains(&"agent start")); - assert!(top_two.contains(&"agent stop")); - } - - #[test] - fn tag_filter_limits_results() { - let hits = search(&[], &["agent"]); - assert!(!hits.is_empty()); - assert!(hits.iter().all(|hit| { - hit.descriptor - .tags - .iter() - .any(|tag| tag.eq_ignore_ascii_case("agent")) - })); - } - - #[test] - fn metadata_has_basic_attributes() { - for descriptor in catalog() { - assert!( - !descriptor.tags.is_empty(), - "command '{}' is missing tags", - descriptor.primary_keyword() - ); - assert!( - !descriptor.modes.is_empty(), - "command '{}' is missing modes", - descriptor.primary_keyword() - ); - } - } -} diff --git a/crates/owlen-tui/src/commands/registry.rs b/crates/owlen-tui/src/commands/registry.rs deleted file mode 100644 index 35e7e7c..0000000 --- a/crates/owlen-tui/src/commands/registry.rs +++ /dev/null @@ -1,171 +0,0 @@ -use std::collections::HashMap; - -use owlen_core::ui::FocusedPanel; - -use crate::{ - state::{KeymapProfile, PaneDirection}, - widgets::model_picker::FilterMode, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum AppCommand { - OpenModelPicker(Option), - OpenCommandPalette, - OpenProviderSwitcher, - CycleFocusForward, - CycleFocusBackward, - FocusPanel(FocusedPanel), - ComposerSubmit, - EnterCommandMode, - ToggleDebugLog, - SetKeymap(KeymapProfile), - JumpToTop, - JumpToBottom, - ExpandFilePanel, - ToggleHiddenFiles, - SplitPaneHorizontal, - SplitPaneVertical, - ClearInputBuffer, - MoveWorkspaceFocus(PaneDirection), -} - -#[derive(Debug)] -pub struct CommandRegistry { - commands: HashMap, -} - -impl CommandRegistry { - pub fn new() -> Self { - let mut commands = HashMap::new(); - - commands.insert( - "model.open_all".to_string(), - AppCommand::OpenModelPicker(None), - ); - commands.insert( - "model.open_local".to_string(), - AppCommand::OpenModelPicker(Some(FilterMode::LocalOnly)), - ); - commands.insert( - "model.open_cloud".to_string(), - AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly)), - ); - commands.insert( - "model.open_available".to_string(), - AppCommand::OpenModelPicker(Some(FilterMode::Available)), - ); - commands.insert("palette.open".to_string(), AppCommand::OpenCommandPalette); - commands.insert( - "provider.switch".to_string(), - AppCommand::OpenProviderSwitcher, - ); - commands.insert("focus.next".to_string(), AppCommand::CycleFocusForward); - commands.insert("focus.prev".to_string(), AppCommand::CycleFocusBackward); - commands.insert( - "focus.files".to_string(), - AppCommand::FocusPanel(FocusedPanel::Files), - ); - commands.insert( - "focus.chat".to_string(), - AppCommand::FocusPanel(FocusedPanel::Chat), - ); - commands.insert( - "focus.thinking".to_string(), - AppCommand::FocusPanel(FocusedPanel::Thinking), - ); - commands.insert( - "focus.input".to_string(), - AppCommand::FocusPanel(FocusedPanel::Input), - ); - commands.insert( - "focus.code".to_string(), - AppCommand::FocusPanel(FocusedPanel::Code), - ); - commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit); - commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode); - commands.insert("debug.toggle".to_string(), AppCommand::ToggleDebugLog); - commands.insert( - "keymap.set_vim".to_string(), - AppCommand::SetKeymap(KeymapProfile::Vim), - ); - commands.insert( - "keymap.set_emacs".to_string(), - AppCommand::SetKeymap(KeymapProfile::Emacs), - ); - commands.insert("navigate.top".to_string(), AppCommand::JumpToTop); - commands.insert("navigate.bottom".to_string(), AppCommand::JumpToBottom); - commands.insert( - "files.focus_expand".to_string(), - AppCommand::ExpandFilePanel, - ); - commands.insert( - "files.toggle_hidden".to_string(), - AppCommand::ToggleHiddenFiles, - ); - commands.insert( - "workspace.split_horizontal".to_string(), - AppCommand::SplitPaneHorizontal, - ); - commands.insert( - "workspace.split_vertical".to_string(), - AppCommand::SplitPaneVertical, - ); - commands.insert("input.clear".to_string(), AppCommand::ClearInputBuffer); - commands.insert( - "workspace.focus_left".to_string(), - AppCommand::MoveWorkspaceFocus(PaneDirection::Left), - ); - commands.insert( - "workspace.focus_right".to_string(), - AppCommand::MoveWorkspaceFocus(PaneDirection::Right), - ); - commands.insert( - "workspace.focus_up".to_string(), - AppCommand::MoveWorkspaceFocus(PaneDirection::Up), - ); - commands.insert( - "workspace.focus_down".to_string(), - AppCommand::MoveWorkspaceFocus(PaneDirection::Down), - ); - - Self { commands } - } - - pub fn resolve(&self, command: &str) -> Option { - self.commands.get(command).copied() - } -} - -impl Default for CommandRegistry { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_known_command() { - let registry = CommandRegistry::new(); - assert_eq!( - registry.resolve("focus.next"), - Some(AppCommand::CycleFocusForward) - ); - assert_eq!( - registry.resolve("model.open_cloud"), - Some(AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly))) - ); - assert_eq!( - registry.resolve("keymap.set_emacs"), - Some(AppCommand::SetKeymap(KeymapProfile::Emacs)) - ); - } - - #[test] - fn resolve_unknown_command() { - let registry = CommandRegistry::new(); - assert_eq!(registry.resolve("does.not.exist"), None); - } -} diff --git a/crates/owlen-tui/src/config.rs b/crates/owlen-tui/src/config.rs deleted file mode 100644 index 4b0d78f..0000000 --- a/crates/owlen-tui/src/config.rs +++ /dev/null @@ -1,16 +0,0 @@ -pub use owlen_core::config::{ - Config, DEFAULT_CONFIG_PATH, GeneralSettings, IconMode, InputSettings, StorageSettings, - UiSettings, default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout, -}; - -/// Attempt to load configuration from default location -pub fn try_load_config() -> Option { - Config::load(None).ok() -} - -/// Persist configuration to default path -pub fn save_config(config: &Config) -> anyhow::Result<()> { - config - .save(None) - .map_err(|e| anyhow::anyhow!(e.to_string())) -} diff --git a/crates/owlen-tui/src/events.rs b/crates/owlen-tui/src/events.rs deleted file mode 100644 index fcc9cd7..0000000 --- a/crates/owlen-tui/src/events.rs +++ /dev/null @@ -1,217 +0,0 @@ -use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent}; -use std::time::Duration; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; - -/// Application events -#[derive(Debug, Clone)] -pub enum Event { - /// Terminal key press event - Key(KeyEvent), - /// Mouse input event - Mouse(MouseEvent), - /// Terminal resize event - #[allow(dead_code)] - Resize(u16, u16), - /// Paste event - Paste(String), - /// Tick event for regular updates - Tick, -} - -/// Convert a raw crossterm event into an application event. -pub fn from_crossterm_event(raw: crossterm::event::Event) -> Option { - match raw { - crossterm::event::Event::Key(key) => { - if key.kind == KeyEventKind::Press { - Some(Event::Key(key)) - } else { - None - } - } - crossterm::event::Event::Mouse(mouse) => Some(Event::Mouse(mouse)), - crossterm::event::Event::Resize(width, height) => Some(Event::Resize(width, height)), - crossterm::event::Event::Paste(text) => Some(Event::Paste(text)), - _ => None, - } -} - -/// Event handler that captures terminal events and sends them to the application -pub struct EventHandler { - sender: mpsc::UnboundedSender, - tick_rate: Duration, - cancellation_token: CancellationToken, -} - -impl EventHandler { - pub fn new( - sender: mpsc::UnboundedSender, - cancellation_token: CancellationToken, - ) -> Self { - Self { - sender, - tick_rate: Duration::from_millis(250), // 4 times per second - cancellation_token, - } - } - - pub async fn run(&self) { - let mut last_tick = tokio::time::Instant::now(); - - loop { - if self.cancellation_token.is_cancelled() { - break; - } - - let timeout = self - .tick_rate - .checked_sub(last_tick.elapsed()) - .unwrap_or_else(|| Duration::from_secs(0)); - - if event::poll(timeout).unwrap_or(false) { - match event::read() { - Ok(event) => { - if let Some(converted) = from_crossterm_event(event) { - let _ = self.sender.send(converted); - } - } - Err(_) => { - // Handle error by continuing the loop - continue; - } - } - } - - if last_tick.elapsed() >= self.tick_rate { - let _ = self.sender.send(Event::Tick); - last_tick = tokio::time::Instant::now(); - } - } - } -} - -/// Helper functions for key event handling -impl Event { - /// Check if this is a quit command (Ctrl+C or 'q') - pub fn is_quit(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Char('q'), - modifiers: KeyModifiers::NONE, - .. - }) | Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - .. - }) - ) - } - - /// Check if this is an enter key press - pub fn is_enter(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Enter, - .. - }) - ) - } - - /// Check if this is a tab key press - #[allow(dead_code)] - pub fn is_tab(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Tab, - modifiers: KeyModifiers::NONE, - .. - }) - ) - } - - /// Check if this is a backspace - pub fn is_backspace(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Backspace, - .. - }) - ) - } - - /// Check if this is an escape key press - pub fn is_escape(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Esc, - .. - }) - ) - } - - /// Get the character if this is a character key event - pub fn as_char(&self) -> Option { - match self { - Event::Key(KeyEvent { - code: KeyCode::Char(c), - modifiers: KeyModifiers::NONE, - .. - }) => Some(*c), - Event::Key(KeyEvent { - code: KeyCode::Char(c), - modifiers: KeyModifiers::SHIFT, - .. - }) => Some(*c), - _ => None, - } - } - - /// Check if this is an up arrow key press - pub fn is_up(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Up, - .. - }) - ) - } - - /// Check if this is a down arrow key press - pub fn is_down(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Down, - .. - }) - ) - } - - /// Check if this is a left arrow key press - pub fn is_left(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Left, - .. - }) - ) - } - - /// Check if this is a right arrow key press - pub fn is_right(&self) -> bool { - matches!( - self, - Event::Key(KeyEvent { - code: KeyCode::Right, - .. - }) - ) - } -} diff --git a/crates/owlen-tui/src/glass.rs b/crates/owlen-tui/src/glass.rs deleted file mode 100644 index 9da0080..0000000 --- a/crates/owlen-tui/src/glass.rs +++ /dev/null @@ -1,254 +0,0 @@ -use owlen_core::{Theme, config::LayerSettings}; -use ratatui::style::{Color, palette::tailwind}; - -#[derive(Clone, Copy)] -pub struct GlassPalette { - pub active: Color, - pub inactive: Color, - pub highlight: Color, - pub track: Color, - pub label: Color, - pub shadow: Color, - pub context_stops: [Color; 3], - pub usage_stops: [Color; 3], - pub frosted: Color, - pub frost_edge: Color, - pub neon_accent: Color, - pub neon_glow: Color, - pub focus_ring: Color, -} - -impl GlassPalette { - pub fn for_theme(theme: &Theme, layers: &LayerSettings) -> Self { - Self::for_theme_with_mode(theme, false, layers) - } - - pub fn for_theme_with_mode( - theme: &Theme, - reduced_chrome: bool, - layers: &LayerSettings, - ) -> Self { - if reduced_chrome { - let base = crate::color_convert::to_ratatui_color(&theme.background); - let label = crate::color_convert::to_ratatui_color(&theme.text); - let track = crate::color_convert::to_ratatui_color(&theme.unfocused_panel_border); - let context_color = crate::color_convert::to_ratatui_color(&theme.mode_normal); - let usage_color = crate::color_convert::to_ratatui_color(&theme.mode_command); - return Self { - active: base, - inactive: base, - highlight: base, - track, - label, - shadow: base, - context_stops: [context_color, context_color, context_color], - usage_stops: [usage_color, usage_color, usage_color], - frosted: base, - frost_edge: base, - neon_accent: crate::color_convert::to_ratatui_color(&theme.info), - neon_glow: crate::color_convert::to_ratatui_color(&theme.info), - focus_ring: crate::color_convert::to_ratatui_color(&theme.focused_panel_border), - }; - } - - let background_ratatui = crate::color_convert::to_ratatui_color(&theme.background); - let luminance = color_luminance(background_ratatui); - let neon_factor = layers.neon_factor(); - let glass_tint = layers.glass_tint_factor(); - let focus_enabled = layers.focus_ring; - if luminance < 0.5 { - let frosted = blend_color(tailwind::SLATE.c900, background_ratatui, glass_tint * 0.65); - let frost_edge = blend_color(frosted, tailwind::SLATE.c700, 0.25); - let inactive = blend_color(frosted, tailwind::SLATE.c800, 0.55); - let highlight = blend_color(frosted, tailwind::SLATE.c700, 0.35); - let track = blend_color(frosted, tailwind::SLATE.c600, 0.25); - let neon_seed = tailwind::SKY.c400; - let info_ratatui = crate::color_convert::to_ratatui_color(&theme.info); - let neon_accent = blend_color(neon_seed, info_ratatui, neon_factor); - let neon_glow = blend_color(neon_accent, Color::White, 0.18); - let focused_border_ratatui = - crate::color_convert::to_ratatui_color(&theme.focused_panel_border); - let unfocused_border_ratatui = - crate::color_convert::to_ratatui_color(&theme.unfocused_panel_border); - let focus_ring = if focus_enabled { - blend_color(neon_accent, focused_border_ratatui, 0.45) - } else { - blend_color(frosted, unfocused_border_ratatui, 0.15) - }; - let shadow = match layers.shadow_depth() { - 0 => blend_color(background_ratatui, tailwind::SLATE.c800, 0.15), - 1 => tailwind::SLATE.c900, - 2 => tailwind::SLATE.c950, - _ => Color::Rgb(2, 4, 12), - }; - - Self { - active: frosted, - inactive, - highlight, - track, - label: tailwind::SLATE.c100, - shadow, - context_stops: [ - blend_color(neon_seed, tailwind::AMBER.c300, 0.3), - tailwind::AMBER.c300, - tailwind::ROSE.c400, - ], - usage_stops: [ - tailwind::CYAN.c400, - tailwind::AMBER.c300, - tailwind::ROSE.c400, - ], - frosted, - frost_edge, - neon_accent, - neon_glow, - focus_ring, - } - } else { - let frosted = blend_color(tailwind::ZINC.c100, background_ratatui, glass_tint * 0.75); - let frost_edge = blend_color(frosted, tailwind::ZINC.c200, 0.4); - let inactive = blend_color(frosted, tailwind::ZINC.c200, 0.65); - let highlight = blend_color(frosted, tailwind::ZINC.c200, 0.35); - let track = blend_color(frosted, tailwind::ZINC.c300, 0.45); - let neon_seed = tailwind::BLUE.c500; - let info_ratatui = crate::color_convert::to_ratatui_color(&theme.info); - let neon_accent = blend_color(neon_seed, info_ratatui, neon_factor); - let neon_glow = blend_color(neon_accent, Color::White, 0.22); - let focused_border_ratatui = - crate::color_convert::to_ratatui_color(&theme.focused_panel_border); - let unfocused_border_ratatui = - crate::color_convert::to_ratatui_color(&theme.unfocused_panel_border); - let focus_ring = if focus_enabled { - blend_color(neon_accent, focused_border_ratatui, 0.35) - } else { - blend_color(frosted, unfocused_border_ratatui, 0.1) - }; - let shadow = match layers.shadow_depth() { - 0 => blend_color(background_ratatui, tailwind::ZINC.c200, 0.12), - 1 => tailwind::ZINC.c300, - 2 => tailwind::ZINC.c400, - _ => Color::Rgb(210, 210, 210), - }; - - Self { - active: frosted, - inactive, - highlight, - track, - label: tailwind::SLATE.c700, - shadow, - context_stops: [ - tailwind::BLUE.c500, - tailwind::AMBER.c400, - tailwind::ROSE.c500, - ], - usage_stops: [ - tailwind::TEAL.c400, - tailwind::AMBER.c400, - tailwind::ROSE.c500, - ], - frosted, - frost_edge, - neon_accent, - neon_glow, - focus_ring, - } - } - } -} - -pub fn gradient_color(stops: &[Color; 3], t: f64) -> Color { - let clamped = t.clamp(0.0, 1.0); - let segments = stops.len().saturating_sub(1).max(1); - let scaled = clamped * segments as f64; - let index = scaled.floor() as usize; - let frac = scaled - index as f64; - let start = stops[index.min(stops.len() - 1)]; - let end = stops[(index + 1).min(stops.len() - 1)]; - let (sr, sg, sb) = color_to_rgb(start); - let (er, eg, eb) = color_to_rgb(end); - let mix = |a: u8, b: u8| -> u8 { (a as f64 + (b as f64 - a as f64) * frac).round() as u8 }; - Color::Rgb(mix(sr, er), mix(sg, eg), mix(sb, eb)) -} - -pub fn blend_color(a: Color, b: Color, t: f64) -> Color { - let clamped = t.clamp(0.0, 1.0); - let (ar, ag, ab) = color_to_rgb(a); - let (br, bg, bb) = color_to_rgb(b); - let mix = |start: u8, end: u8| -> u8 { - (start as f64 + (end as f64 - start as f64) * clamped).round() as u8 - }; - Color::Rgb(mix(ar, br), mix(ag, bg), mix(ab, bb)) -} - -fn color_luminance(color: Color) -> f64 { - let (r, g, b) = color_to_rgb(color); - let r = r as f64 / 255.0; - let g = g as f64 / 255.0; - let b = b as f64 / 255.0; - 0.2126 * r + 0.7152 * g + 0.0722 * b -} - -fn color_to_rgb(color: Color) -> (u8, u8, u8) { - match color { - Color::Reset => (0, 0, 0), - Color::Black => (0, 0, 0), - Color::Red => (205, 49, 49), - Color::Green => (49, 205, 49), - Color::Yellow => (205, 198, 49), - Color::Blue => (49, 49, 205), - Color::Magenta => (205, 49, 205), - Color::Cyan => (49, 205, 205), - Color::Gray => (170, 170, 170), - Color::DarkGray => (100, 100, 100), - Color::LightRed => (255, 128, 128), - Color::LightGreen => (144, 238, 144), - Color::LightYellow => (255, 255, 170), - Color::LightBlue => (173, 216, 230), - Color::LightMagenta => (255, 182, 255), - Color::LightCyan => (175, 238, 238), - Color::White => (255, 255, 255), - Color::Rgb(r, g, b) => (r, g, b), - Color::Indexed(idx) => indexed_to_rgb(idx), - } -} - -fn indexed_to_rgb(idx: u8) -> (u8, u8, u8) { - match idx { - 0 => (0, 0, 0), - 1 => (128, 0, 0), - 2 => (0, 128, 0), - 3 => (128, 128, 0), - 4 => (0, 0, 128), - 5 => (128, 0, 128), - 6 => (0, 128, 128), - 7 => (192, 192, 192), - 8 => (128, 128, 128), - 9 => (255, 0, 0), - 10 => (0, 255, 0), - 11 => (255, 255, 0), - 12 => (92, 92, 255), - 13 => (255, 0, 255), - 14 => (0, 255, 255), - 15 => (255, 255, 255), - 16..=231 => { - let idx = idx - 16; - let r = idx / 36; - let g = (idx % 36) / 6; - let b = idx % 6; - let convert = |component: u8| { - if component == 0 { - 0 - } else { - component.saturating_mul(40).saturating_add(55) - } - }; - (convert(r), convert(g), convert(b)) - } - 232..=255 => { - let shade = 8 + (idx - 232) * 10; - (shade, shade, shade) - } - } -} diff --git a/crates/owlen-tui/src/highlight.rs b/crates/owlen-tui/src/highlight.rs deleted file mode 100644 index 48854eb..0000000 --- a/crates/owlen-tui/src/highlight.rs +++ /dev/null @@ -1,162 +0,0 @@ -use once_cell::sync::Lazy; -use ratatui::style::{Color as TuiColor, Modifier, Style as TuiStyle}; -use std::path::{Path, PathBuf}; -use syntect::easy::HighlightLines; -use syntect::highlighting::{FontStyle, Style as SynStyle, Theme, ThemeSet}; -use syntect::parsing::{SyntaxReference, SyntaxSet}; - -static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); -static THEME_SET: Lazy = Lazy::new(ThemeSet::load_defaults); -static THEME: Lazy = Lazy::new(|| { - THEME_SET - .themes - .get("base16-ocean.dark") - .cloned() - .or_else(|| THEME_SET.themes.values().next().cloned()) - .unwrap_or_default() -}); - -fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference { - if let Some(path) = path_hint - && let Ok(Some(syntax)) = SYNTAX_SET.find_syntax_for_file(path) - { - return syntax; - } - if let Some(path) = path_hint - && let Some(ext) = path.extension().and_then(|ext| ext.to_str()) - && let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) - { - return syntax; - } - if let Some(path) = path_hint - && let Some(name) = path.file_name().and_then(|name| name.to_str()) - && let Some(syntax) = SYNTAX_SET.find_syntax_by_token(name) - { - return syntax; - } - - SYNTAX_SET.find_syntax_plain_text() -} - -fn select_syntax_for_language(language: Option<&str>) -> &'static SyntaxReference { - let token = language - .map(|lang| lang.trim().to_ascii_lowercase()) - .filter(|lang| !lang.is_empty()); - - if let Some(token) = token { - let mut attempts: Vec<&str> = vec![token.as_str()]; - match token.as_str() { - "c++" => attempts.extend(["cpp", "c"]), - "c#" | "cs" => attempts.extend(["csharp", "cs"]), - "shell" => attempts.extend(["bash", "sh"]), - "typescript" | "ts" => attempts.extend(["typescript", "ts", "tsx"]), - "javascript" | "js" => attempts.extend(["javascript", "js", "jsx"]), - "py" => attempts.push("python"), - "rs" => attempts.push("rust"), - "yml" => attempts.push("yaml"), - other => { - if let Some(stripped) = other.strip_prefix('.') { - attempts.push(stripped); - } - } - } - - for candidate in attempts { - if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(candidate) { - return syntax; - } - if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(candidate) { - return syntax; - } - } - } - - SYNTAX_SET.find_syntax_plain_text() -} - -fn path_hint_from_components(absolute: Option<&Path>, display: Option<&str>) -> Option { - if let Some(abs) = absolute { - return Some(abs.to_path_buf()); - } - display.map(PathBuf::from) -} - -fn style_from_syntect(style: SynStyle) -> TuiStyle { - let mut tui_style = TuiStyle::default().fg(TuiColor::Rgb( - style.foreground.r, - style.foreground.g, - style.foreground.b, - )); - - let mut modifiers = Modifier::empty(); - if style.font_style.contains(FontStyle::BOLD) { - modifiers |= Modifier::BOLD; - } - if style.font_style.contains(FontStyle::ITALIC) { - modifiers |= Modifier::ITALIC; - } - if style.font_style.contains(FontStyle::UNDERLINE) { - modifiers |= Modifier::UNDERLINED; - } - - if !modifiers.is_empty() { - tui_style = tui_style.add_modifier(modifiers); - } - - tui_style -} - -pub fn build_highlighter( - absolute: Option<&Path>, - display: Option<&str>, -) -> HighlightLines<'static> { - let hint_path = path_hint_from_components(absolute, display); - let syntax = select_syntax(hint_path.as_deref()); - HighlightLines::new(syntax, &THEME) -} - -pub fn highlight_line( - highlighter: &mut HighlightLines<'static>, - line: &str, -) -> Vec<(TuiStyle, String)> { - let mut segments = Vec::new(); - match highlighter.highlight_line(line, &SYNTAX_SET) { - Ok(result) => { - for (style, piece) in result { - let tui_style = style_from_syntect(style); - segments.push((tui_style, piece.to_string())); - } - } - Err(_) => { - segments.push((TuiStyle::default(), line.to_string())); - } - } - - if segments.is_empty() { - segments.push((TuiStyle::default(), String::new())); - } - - segments -} - -pub fn build_highlighter_for_language(language: Option<&str>) -> HighlightLines<'static> { - let syntax = select_syntax_for_language(language); - HighlightLines::new(syntax, &THEME) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rust_highlighting_produces_colored_segment() { - let mut highlighter = build_highlighter_for_language(Some("rust")); - let segments = highlight_line(&mut highlighter, "fn main() {}"); - assert!( - segments - .iter() - .any(|(style, text)| style.fg.is_some() && !text.trim().is_empty()), - "Expected at least one colored segment" - ); - } -} diff --git a/crates/owlen-tui/src/lib.rs b/crates/owlen-tui/src/lib.rs deleted file mode 100644 index 5d3a9aa..0000000 --- a/crates/owlen-tui/src/lib.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! # Owlen TUI -//! -//! This crate contains all the logic for the terminal user interface (TUI) of Owlen. -//! -//! It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for -//! rendering the chat interface, handling user input, and managing the application state. -//! -//! ## Modules -//! - `chat_app`: The main application logic for the chat client. -//! - `code_app`: The main application logic for the experimental code client. -//! - `config`: TUI-specific configuration. -//! - `events`: Event handling for user input and other asynchronous actions. -//! - `ui`: The rendering logic for all TUI components. - -pub mod app; -pub mod chat_app; -pub mod code_app; -pub mod color_convert; -pub mod commands; -pub mod config; -pub mod events; -pub(crate) mod glass; -pub mod highlight; -pub mod model_info_panel; -pub mod slash; -pub mod state; -pub mod toast; -pub mod tui_controller; -pub mod ui; -pub mod widgets; - -pub use chat_app::{ChatApp, SessionEvent}; -pub use code_app::CodeApp; -pub use events::{Event, EventHandler}; -pub use owlen_core::ui::{AppState, FocusedPanel, InputMode}; diff --git a/crates/owlen-tui/src/model_info_panel.rs b/crates/owlen-tui/src/model_info_panel.rs deleted file mode 100644 index f864882..0000000 --- a/crates/owlen-tui/src/model_info_panel.rs +++ /dev/null @@ -1,232 +0,0 @@ -use owlen_core::Theme; -use owlen_core::model::DetailedModelInfo; -use ratatui::{ - Frame, - layout::Rect, - style::{Modifier, Style}, - widgets::{Block, Borders, Paragraph, Wrap}, -}; - -/// Dedicated panel for presenting detailed model information. -pub struct ModelInfoPanel { - info: Option, - scroll_offset: usize, - total_lines: usize, -} - -impl ModelInfoPanel { - pub fn new() -> Self { - Self { - info: None, - scroll_offset: 0, - total_lines: 0, - } - } - - pub fn set_model_info(&mut self, info: DetailedModelInfo) { - self.info = Some(info); - self.scroll_offset = 0; - self.total_lines = 0; - } - - pub fn clear(&mut self) { - self.info = None; - self.scroll_offset = 0; - self.total_lines = 0; - } - - pub fn render(&mut self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) { - let block = Block::default() - .title("Model Information") - .borders(Borders::ALL) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - ))); - - if let Some(info) = &self.info { - let body = self.format_info(info); - self.total_lines = body.lines().count(); - let paragraph = Paragraph::new(body) - .block(block) - .style(Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text))) - .wrap(Wrap { trim: true }) - .scroll((self.scroll_offset as u16, 0)); - frame.render_widget(paragraph, area); - } else { - self.total_lines = 0; - let paragraph = Paragraph::new("Select a model to inspect its details.") - .block(block) - .style( - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - ) - .wrap(Wrap { trim: true }); - frame.render_widget(paragraph, area); - } - } - - pub fn scroll_up(&mut self) { - if self.scroll_offset > 0 { - self.scroll_offset -= 1; - } - } - - pub fn scroll_down(&mut self, viewport_height: usize) { - if viewport_height == 0 { - return; - } - let max_offset = self.total_lines.saturating_sub(viewport_height); - if self.scroll_offset < max_offset { - self.scroll_offset += 1; - } - } - - pub fn reset_scroll(&mut self) { - self.scroll_offset = 0; - } - - pub fn scroll_offset(&self) -> usize { - self.scroll_offset - } - - pub fn total_lines(&self) -> usize { - self.total_lines - } - - pub fn current_model_name(&self) -> Option<&str> { - self.info.as_ref().map(|info| info.name.as_str()) - } - - fn format_info(&self, info: &DetailedModelInfo) -> String { - let mut lines = Vec::new(); - lines.push(format!("Name: {}", info.name)); - lines.push(format!( - "Architecture: {}", - display_option(info.architecture.as_deref()) - )); - lines.push(format!( - "Parameters: {}", - display_option(info.parameters.as_deref()) - )); - lines.push(format!( - "Context Length: {}", - display_u64(info.context_length) - )); - lines.push(format!( - "Embedding Length: {}", - display_u64(info.embedding_length) - )); - lines.push(format!( - "Quantization: {}", - display_option(info.quantization.as_deref()) - )); - lines.push(format!( - "Family: {}", - display_option(info.family.as_deref()) - )); - if !info.families.is_empty() { - lines.push(format!("Families: {}", info.families.join(", "))); - } - lines.push(format!( - "Parameter Size: {}", - display_option(info.parameter_size.as_deref()) - )); - lines.push(format!("Size: {}", format_size(info.size))); - lines.push(format!( - "Modified: {}", - display_option(info.modified_at.as_deref()) - )); - lines.push(format!( - "License: {}", - display_option(info.license.as_deref()) - )); - lines.push(format!( - "Digest: {}", - display_option(info.digest.as_deref()) - )); - - if let Some(template) = info.template.as_deref() { - lines.push(format!("Template: {}", snippet(template))); - } - - if let Some(system) = info.system.as_deref() { - lines.push(format!("System Prompt: {}", snippet(system))); - } - - if let Some(modelfile) = info.modelfile.as_deref() { - lines.push("Modelfile:".to_string()); - lines.push(snippet_multiline(modelfile, 8)); - } - - lines.join("\n") - } -} - -impl Default for ModelInfoPanel { - fn default() -> Self { - Self::new() - } -} - -fn display_option(value: Option<&str>) -> String { - value - .map(|s| s.to_string()) - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "N/A".to_string()) -} - -fn display_u64(value: Option) -> String { - value - .map(|v| v.to_string()) - .unwrap_or_else(|| "N/A".to_string()) -} - -fn format_size(value: Option) -> String { - if let Some(bytes) = value { - if bytes >= 1_000_000_000 { - let human = bytes as f64 / 1_000_000_000_f64; - format!("{human:.2} GB ({} bytes)", bytes) - } else if bytes >= 1_000_000 { - let human = bytes as f64 / 1_000_000_f64; - format!("{human:.2} MB ({} bytes)", bytes) - } else if bytes >= 1_000 { - let human = bytes as f64 / 1_000_f64; - format!("{human:.2} KB ({} bytes)", bytes) - } else { - format!("{bytes} bytes") - } - } else { - "N/A".to_string() - } -} - -fn snippet(text: &str) -> String { - const MAX_LEN: usize = 160; - if text.len() > MAX_LEN { - format!("{}…", text.chars().take(MAX_LEN).collect::()) - } else { - text.to_string() - } -} - -fn snippet_multiline(text: &str, max_lines: usize) -> String { - let mut lines = Vec::new(); - for (idx, line) in text.lines().enumerate() { - if idx >= max_lines { - lines.push("…".to_string()); - break; - } - lines.push(snippet(line)); - } - if lines.is_empty() { - "N/A".to_string() - } else { - lines.join("\n") - } -} diff --git a/crates/owlen-tui/src/slash.rs b/crates/owlen-tui/src/slash.rs deleted file mode 100644 index 022b28e..0000000 --- a/crates/owlen-tui/src/slash.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! Slash command parsing for chat input. -//! -//! Provides lightweight handling for inline commands such as `/summarize` -//! and `/testplan`. The parser returns owned data so callers can prepare -//! requests immediately without additional lifetime juggling. - -use std::collections::HashMap; -use std::fmt; -use std::str::FromStr; -use std::sync::{OnceLock, RwLock}; - -/// Supported slash commands. -#[derive(Debug, Clone)] -pub enum SlashCommand { - Summarize { count: Option }, - Explain { snippet: String }, - Refactor { path: String }, - TestPlan, - Compact, - McpTool { server: String, tool: String }, -} - -/// Errors emitted when parsing invalid slash input. -#[derive(Debug)] -pub enum SlashError { - UnknownCommand(String), - Message(String), -} - -impl fmt::Display for SlashError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SlashError::UnknownCommand(cmd) => write!(f, "unknown slash command: {cmd}"), - SlashError::Message(msg) => f.write_str(msg), - } - } -} - -impl std::error::Error for SlashError {} - -#[derive(Debug, Clone)] -pub struct McpSlashCommand { - pub server: String, - pub tool: String, - pub keyword: String, - pub description: Option, -} - -impl McpSlashCommand { - pub fn new( - server: impl Into, - tool: impl Into, - description: Option, - ) -> Self { - let server = server.into(); - let tool = tool.into(); - let keyword = format!( - "mcp__{}__{}", - canonicalize_component(&server), - canonicalize_component(&tool) - ); - Self { - server, - tool, - keyword, - description, - } - } -} - -static MCP_COMMANDS: OnceLock>> = OnceLock::new(); - -fn dynamic_registry() -> &'static RwLock> { - MCP_COMMANDS.get_or_init(|| RwLock::new(HashMap::new())) -} - -pub fn set_mcp_commands(commands: impl IntoIterator) { - let registry = dynamic_registry(); - let mut guard = registry.write().expect("MCP command registry poisoned"); - guard.clear(); - for command in commands { - guard.insert(command.keyword.clone(), command); - } -} - -fn find_mcp_command(keyword: &str) -> Option { - let registry = dynamic_registry(); - let guard = registry.read().expect("MCP command registry poisoned"); - guard.get(keyword).cloned() -} - -fn canonicalize_component(input: &str) -> String { - let mut out = String::new(); - let mut last_was_underscore = false; - for ch in input.chars() { - let mapped = if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - '_' - }; - if mapped == '_' { - if !last_was_underscore { - out.push('_'); - last_was_underscore = true; - } - } else { - out.push(mapped); - last_was_underscore = false; - } - } - if out.is_empty() { "_".to_string() } else { out } -} - -/// Attempt to parse a slash command from the provided input. -pub fn parse(input: &str) -> Result, SlashError> { - let trimmed = input.trim(); - if !trimmed.starts_with('/') { - return Ok(None); - } - - let body = trimmed.trim_start_matches('/'); - if body.is_empty() { - return Err(SlashError::Message("missing command name after '/'".into())); - } - - let mut parts = body.split_whitespace(); - let command = parts.next().unwrap(); - let remainder = parts.collect::>(); - - if let Some(dynamic) = find_mcp_command(command) { - if !remainder.is_empty() { - return Err(SlashError::Message(format!( - "/{} does not accept arguments", - dynamic.keyword - ))); - } - return Ok(Some(SlashCommand::McpTool { - server: dynamic.server, - tool: dynamic.tool, - })); - } - - let cmd = match command { - "summarize" => { - let count = remainder - .first() - .and_then(|value| usize::from_str(value).ok()); - SlashCommand::Summarize { count } - } - "explain" => { - if remainder.is_empty() { - return Err(SlashError::Message( - "usage: /explain ".into(), - )); - } - SlashCommand::Explain { - snippet: remainder.join(" "), - } - } - "refactor" => { - if remainder.is_empty() { - return Err(SlashError::Message( - "usage: /refactor ".into(), - )); - } - SlashCommand::Refactor { - path: remainder.join(" "), - } - } - "testplan" => SlashCommand::TestPlan, - "compact" => SlashCommand::Compact, - other => return Err(SlashError::UnknownCommand(other.to_string())), - }; - - Ok(Some(cmd)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn registry_guard() -> std::sync::MutexGuard<'static, ()> { - static GUARD: std::sync::OnceLock> = std::sync::OnceLock::new(); - GUARD - .get_or_init(|| std::sync::Mutex::new(())) - .lock() - .expect("registry test mutex poisoned") - } - - #[test] - fn ignores_non_command_input() { - let result = parse("hello world").unwrap(); - assert!(result.is_none()); - } - - #[test] - fn parses_summarize_with_count() { - let command = parse("/summarize 10").unwrap().expect("expected command"); - match command { - SlashCommand::Summarize { count } => assert_eq!(count, Some(10)), - other => panic!("unexpected command: {:?}", other), - } - } - - #[test] - fn returns_error_for_unknown_command() { - let err = parse("/unknown").unwrap_err(); - assert_eq!(err.to_string(), "unknown slash command: unknown"); - } - - #[test] - fn parses_registered_mcp_command() { - let _registry = registry_guard(); - set_mcp_commands(Vec::new()); - set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]); - - let command = parse("/mcp__github__list_prs") - .unwrap() - .expect("expected command"); - match command { - SlashCommand::McpTool { server, tool } => { - assert_eq!(server, "github"); - assert_eq!(tool, "list_prs"); - } - other => panic!("unexpected command variant: {:?}", other), - } - } - - #[test] - fn rejects_mcp_command_with_arguments() { - let _registry = registry_guard(); - set_mcp_commands(Vec::new()); - set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]); - - let err = parse("/mcp__github__list_prs extra").unwrap_err(); - assert_eq!( - err.to_string(), - "/mcp__github__list_prs does not accept arguments" - ); - } - - #[test] - fn canonicalizes_mcp_command_components() { - set_mcp_commands(Vec::new()); - let entry = McpSlashCommand::new("GitHub", "list/prs", None); - assert_eq!(entry.keyword, "mcp__github__list_prs"); - } -} diff --git a/crates/owlen-tui/src/state/command_palette.rs b/crates/owlen-tui/src/state/command_palette.rs deleted file mode 100644 index c8a320a..0000000 --- a/crates/owlen-tui/src/state/command_palette.rs +++ /dev/null @@ -1,542 +0,0 @@ -use crate::commands; -use std::collections::{HashSet, VecDeque}; - -const MAX_RESULTS: usize = 12; -const MAX_HISTORY_RESULTS: usize = 4; -const HISTORY_CAPACITY: usize = 20; - -/// Encapsulates the command-line style palette used in command mode. -/// -/// The palette keeps track of the raw buffer, matching suggestions, and the -/// currently highlighted suggestion index. It contains no terminal-specific -/// logic which makes it straightforward to unit test. - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PaletteGroup { - History, - Command, - Model, - Provider, -} - -#[derive(Debug, Clone)] -pub struct PaletteSuggestion { - pub value: String, - pub label: String, - pub detail: Option, - pub group: PaletteGroup, - pub category: Option, - pub modes: Vec, - pub keybinding: Option, - pub tags: Vec, - pub preview: Option, -} - -#[derive(Debug, Clone)] -pub struct PalettePreview { - pub title: String, - pub body: Vec, -} - -#[derive(Debug, Clone)] -pub struct ModelPaletteEntry { - pub id: String, - pub name: String, - pub provider: String, -} - -impl ModelPaletteEntry { - fn display_name(&self) -> &str { - if self.name.is_empty() { - &self.id - } else { - &self.name - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct CommandPalette { - buffer: String, - suggestions: Vec, - selected: usize, - models: Vec, - providers: Vec, - history: VecDeque, -} - -impl CommandPalette { - pub fn new() -> Self { - Self::default() - } - - pub fn buffer(&self) -> &str { - &self.buffer - } - - pub fn suggestions(&self) -> &[PaletteSuggestion] { - &self.suggestions - } - - pub fn selected_index(&self) -> usize { - self.selected - } - - pub fn clear(&mut self) { - self.buffer.clear(); - self.suggestions.clear(); - self.selected = 0; - } - - pub fn remember(&mut self, value: impl AsRef) { - let trimmed = value.as_ref().trim(); - if trimmed.is_empty() { - return; - } - - // Avoid duplicate consecutive entries by removing any existing matching value. - if let Some(pos) = self - .history - .iter() - .position(|entry| entry.eq_ignore_ascii_case(trimmed)) - { - self.history.remove(pos); - } - - self.history.push_back(trimmed.to_string()); - - while self.history.len() > HISTORY_CAPACITY { - self.history.pop_front(); - } - } - - pub fn set_buffer(&mut self, value: impl Into) { - self.buffer = value.into(); - self.refresh_suggestions(); - } - - pub fn push_char(&mut self, ch: char) { - self.buffer.push(ch); - self.refresh_suggestions(); - } - - pub fn pop_char(&mut self) { - self.buffer.pop(); - self.refresh_suggestions(); - } - - pub fn update_dynamic_sources( - &mut self, - models: Vec, - providers: Vec, - ) { - self.models = models; - self.providers = providers; - self.refresh_suggestions(); - } - - pub fn select_previous(&mut self) { - if !self.suggestions.is_empty() { - self.selected = self.selected.saturating_sub(1); - } - } - - pub fn select_next(&mut self) { - if !self.suggestions.is_empty() { - let max_index = self.suggestions.len().saturating_sub(1); - self.selected = (self.selected + 1).min(max_index); - } - } - - pub fn apply_selected(&mut self) -> Option { - let selected = self - .suggestions - .get(self.selected) - .cloned() - .or_else(|| self.suggestions.first().cloned()); - if let Some(entry) = selected.clone() { - self.buffer = entry.value.clone(); - self.refresh_suggestions(); - } - selected.map(|entry| entry.value) - } - - pub fn refresh_suggestions(&mut self) { - let trimmed = self.buffer.trim(); - self.suggestions = self.dynamic_suggestions(trimmed); - if self.selected >= self.suggestions.len() { - self.selected = 0; - } - } - - pub fn ensure_suggestions(&mut self) { - if self.suggestions.is_empty() { - self.refresh_suggestions(); - } - } - - fn dynamic_suggestions(&self, trimmed: &str) -> Vec { - let query = QueryParts::from_input(trimmed); - let lowered = query.text.to_ascii_lowercase(); - let mut results: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - - fn push_entries( - results: &mut Vec, - seen: &mut HashSet, - entries: Vec, - ) { - for entry in entries { - if seen.insert(entry.value.to_ascii_lowercase()) { - results.push(entry); - } - if results.len() >= MAX_RESULTS { - break; - } - } - } - - let history = self.history_suggestions(&query); - push_entries(&mut results, &mut seen, history); - if results.len() >= MAX_RESULTS { - return results; - } - - if !query.tags.is_empty() && query.terms.is_empty() { - // Only tag filters are active; restrict results to matching commands. - push_entries(&mut results, &mut seen, self.command_entries(&query)); - return results; - } - - if lowered.starts_with("model ") { - let rest = query.text.get(5..).unwrap_or_default().trim(); - push_entries( - &mut results, - &mut seen, - self.model_suggestions("model", rest), - ); - if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(&query)); - } - return results; - } - - if lowered.starts_with("m ") { - let rest = query.text.get(2..).unwrap_or_default().trim(); - push_entries(&mut results, &mut seen, self.model_suggestions("m", rest)); - if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(&query)); - } - return results; - } - - if lowered == "model" { - push_entries(&mut results, &mut seen, self.model_suggestions("model", "")); - if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(&query)); - } - return results; - } - - if lowered.starts_with("provider ") { - let rest = query.text.get(9..).unwrap_or_default().trim(); - push_entries( - &mut results, - &mut seen, - self.provider_suggestions("provider", rest), - ); - if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(&query)); - } - return results; - } - - if lowered == "provider" { - push_entries( - &mut results, - &mut seen, - self.provider_suggestions("provider", ""), - ); - if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(&query)); - } - return results; - } - - // General query – combine commands, models, and providers using fuzzy order. - push_entries(&mut results, &mut seen, self.command_entries(&query)); - if results.len() < MAX_RESULTS && query.tags.is_empty() { - push_entries( - &mut results, - &mut seen, - self.model_suggestions("model", query.text.trim()), - ); - } - if results.len() < MAX_RESULTS && query.tags.is_empty() { - push_entries( - &mut results, - &mut seen, - self.provider_suggestions("provider", query.text.trim()), - ); - } - - results - } - - fn history_suggestions(&self, query: &QueryParts) -> Vec { - if self.history.is_empty() { - return Vec::new(); - } - - if !query.tags.is_empty() && query.terms.is_empty() { - return Vec::new(); - } - - if query.text.trim().is_empty() { - return self - .history - .iter() - .rev() - .take(MAX_HISTORY_RESULTS) - .map(|value| PaletteSuggestion { - value: value.to_string(), - label: value.to_string(), - detail: Some("Recent command".to_string()), - group: PaletteGroup::History, - category: None, - modes: vec![], - keybinding: None, - tags: vec!["history".to_string()], - preview: None, - }) - .collect(); - } - - let mut matches: Vec<(usize, usize, usize, &String)> = self - .history - .iter() - .rev() - .enumerate() - .filter_map(|(recency, value)| { - commands::match_score(value, query.text.as_str()) - .map(|(primary, secondary)| (primary, secondary, recency, value)) - }) - .collect(); - - matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); - - matches - .into_iter() - .take(MAX_HISTORY_RESULTS) - .map(|(_, _, _, value)| PaletteSuggestion { - value: value.to_string(), - label: value.to_string(), - detail: Some("Recent command".to_string()), - group: PaletteGroup::History, - category: None, - modes: vec![], - keybinding: None, - tags: vec!["history".to_string()], - preview: None, - }) - .collect() - } - - fn command_entries(&self, query: &QueryParts) -> Vec { - let term_refs: Vec<&str> = query.terms.iter().map(|s| s.as_str()).collect(); - let tag_refs: Vec<&str> = query.tags.iter().map(|s| s.as_str()).collect(); - let hits = commands::search(&term_refs, &tag_refs); - - hits.into_iter() - .map(|hit| { - let descriptor = hit.descriptor; - PaletteSuggestion { - value: hit.keyword.to_string(), - label: hit.keyword.to_string(), - detail: Some(descriptor.description.to_string()), - group: PaletteGroup::Command, - category: Some(descriptor.category.label().to_string()), - modes: descriptor - .modes - .iter() - .map(|mode| mode.to_string()) - .collect(), - keybinding: descriptor.keybinding.map(|binding| binding.to_string()), - tags: descriptor.tags.iter().map(|tag| tag.to_string()).collect(), - preview: descriptor.preview.map(|preview| PalettePreview { - title: preview.title.to_string(), - body: preview - .body - .iter() - .map(|line| (*line).to_string()) - .collect(), - }), - } - }) - .collect() - } - - fn model_suggestions(&self, keyword: &str, query: &str) -> Vec { - if query.is_empty() { - return self - .models - .iter() - .take(15) - .map(|entry| PaletteSuggestion { - value: format!("{keyword} {}", entry.id), - label: entry.display_name().to_string(), - detail: Some(format!("Model · {}", entry.provider)), - group: PaletteGroup::Model, - category: Some("Models".to_string()), - modes: vec!["Command".to_string()], - keybinding: None, - tags: vec!["model".to_string()], - preview: None, - }) - .collect(); - } - - let mut matches: Vec<(usize, usize, &ModelPaletteEntry)> = self - .models - .iter() - .filter_map(|entry| { - commands::match_score(entry.id.as_str(), query) - .or_else(|| commands::match_score(entry.name.as_str(), query)) - .or_else(|| { - let composite = format!("{} {}", entry.provider, entry.display_name()); - commands::match_score(composite.as_str(), query) - }) - .map(|score| (score.0, score.1, entry)) - }) - .collect(); - - matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.id.cmp(&b.2.id))); - matches - .into_iter() - .take(15) - .map(|(_, _, entry)| PaletteSuggestion { - value: format!("{keyword} {}", entry.id), - label: entry.display_name().to_string(), - detail: Some(format!("Model · {}", entry.provider)), - group: PaletteGroup::Model, - category: Some("Models".to_string()), - modes: vec!["Command".to_string()], - keybinding: None, - tags: vec!["model".to_string()], - preview: None, - }) - .collect() - } - - fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec { - if query.is_empty() { - return self - .providers - .iter() - .take(15) - .map(|provider| PaletteSuggestion { - value: format!("{keyword} {}", provider), - label: provider.to_string(), - detail: Some("Provider".to_string()), - group: PaletteGroup::Provider, - category: Some("Providers".to_string()), - modes: vec!["Command".to_string()], - keybinding: None, - tags: vec!["provider".to_string()], - preview: None, - }) - .collect(); - } - - let mut matches: Vec<(usize, usize, &String)> = self - .providers - .iter() - .filter_map(|provider| { - commands::match_score(provider.as_str(), query) - .map(|score| (score.0, score.1, provider)) - }) - .collect(); - - matches.sort(); - matches - .into_iter() - .take(15) - .map(|(_, _, provider)| PaletteSuggestion { - value: format!("{keyword} {}", provider), - label: provider.to_string(), - detail: Some("Provider".to_string()), - group: PaletteGroup::Provider, - category: Some("Providers".to_string()), - modes: vec!["Command".to_string()], - keybinding: None, - tags: vec!["provider".to_string()], - preview: None, - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn history_entries_are_prioritized() { - let mut palette = CommandPalette::new(); - palette.remember("open foo.rs"); - palette.remember("model llama"); - palette.ensure_suggestions(); - - let suggestions = palette.suggestions(); - assert!(!suggestions.is_empty()); - assert_eq!(suggestions[0].value, "model llama"); - assert!(matches!(suggestions[0].group, PaletteGroup::History)); - } - - #[test] - fn history_deduplicates_case_insensitively() { - let mut palette = CommandPalette::new(); - palette.remember("open foo.rs"); - palette.remember("OPEN FOO.RS"); - palette.ensure_suggestions(); - - let history_entries: Vec<_> = palette - .suggestions() - .iter() - .filter(|entry| matches!(entry.group, PaletteGroup::History)) - .collect(); - - assert_eq!(history_entries.len(), 1); - assert_eq!(history_entries[0].value, "OPEN FOO.RS"); - } -} - -#[derive(Debug, Default)] -struct QueryParts { - text: String, - terms: Vec, - tags: Vec, -} - -impl QueryParts { - fn from_input(input: &str) -> Self { - let mut raw_terms: Vec<&str> = Vec::new(); - let mut terms: Vec = Vec::new(); - let mut tags: Vec = Vec::new(); - - for token in input.split_whitespace() { - if let Some(stripped) = token.strip_prefix('/') { - let tag = stripped.trim(); - if !tag.is_empty() { - tags.push(tag.to_ascii_lowercase()); - } - } else { - raw_terms.push(token); - terms.push(token.to_ascii_lowercase()); - } - } - - let text = raw_terms.join(" "); - Self { text, terms, tags } - } -} diff --git a/crates/owlen-tui/src/state/debug_log.rs b/crates/owlen-tui/src/state/debug_log.rs deleted file mode 100644 index f503684..0000000 --- a/crates/owlen-tui/src/state/debug_log.rs +++ /dev/null @@ -1,235 +0,0 @@ -use chrono::{DateTime, Local}; -use log::{Level, LevelFilter, Metadata, Record}; -use once_cell::sync::{Lazy, OnceCell}; -use regex::Regex; -use std::collections::VecDeque; -use std::sync::Mutex; - -/// Maximum number of entries to retain in the in-memory ring buffer. -const MAX_ENTRIES: usize = 256; - -/// Global access handle for the debug log store. -static STORE: Lazy = Lazy::new(DebugLogStore::default); -static LOGGER: OnceCell<()> = OnceCell::new(); -static DEBUG_LOGGER: DebugLogger = DebugLogger; - -/// Install the in-process logger that feeds the debug log ring buffer. -pub fn install_global_logger() { - LOGGER.get_or_init(|| { - if log::set_logger(&DEBUG_LOGGER).is_ok() { - log::set_max_level(LevelFilter::Trace); - } - }); -} - -/// Per-application state for presenting and acknowledging debug log entries. -#[derive(Debug)] -pub struct DebugLogState { - visible: bool, - last_seen_id: u64, -} - -impl DebugLogState { - pub fn new() -> Self { - let last_seen_id = STORE.latest_id(); - Self { - visible: false, - last_seen_id, - } - } - - pub fn toggle_visible(&mut self) -> bool { - self.visible = !self.visible; - if self.visible { - self.mark_seen(); - } - self.visible - } - - pub fn set_visible(&mut self, visible: bool) { - self.visible = visible; - if visible { - self.mark_seen(); - } - } - - pub fn is_visible(&self) -> bool { - self.visible - } - - pub fn entries(&self) -> Vec { - STORE.snapshot() - } - - pub fn take_unseen(&mut self) -> Vec { - let entries = STORE.entries_since(self.last_seen_id); - if let Some(entry) = entries.last() { - self.last_seen_id = entry.id; - } - entries - } - - pub fn has_unseen(&self) -> bool { - STORE.latest_id() > self.last_seen_id - } - - fn mark_seen(&mut self) { - self.last_seen_id = STORE.latest_id(); - } -} - -impl Default for DebugLogState { - fn default() -> Self { - Self::new() - } -} - -/// Metadata describing a single debug log entry. -#[derive(Clone, Debug)] -pub struct DebugLogEntry { - pub id: u64, - pub timestamp: DateTime, - pub level: Level, - pub target: String, - pub message: String, -} - -#[derive(Default)] -struct DebugLogStore { - inner: Mutex, -} - -#[derive(Default)] -struct Inner { - entries: VecDeque, - next_id: u64, -} - -impl DebugLogStore { - fn snapshot(&self) -> Vec { - let inner = self.inner.lock().unwrap(); - inner.entries.iter().cloned().collect() - } - - fn latest_id(&self) -> u64 { - let inner = self.inner.lock().unwrap(); - inner.next_id - } - - fn entries_since(&self, last_seen_id: u64) -> Vec { - let inner = self.inner.lock().unwrap(); - inner - .entries - .iter() - .filter(|entry| entry.id > last_seen_id) - .cloned() - .collect() - } - - fn push(&self, level: Level, target: &str, message: &str) -> DebugLogEntry { - let sanitized = sanitize_message(message); - let mut inner = self.inner.lock().unwrap(); - inner.next_id = inner.next_id.saturating_add(1); - let entry = DebugLogEntry { - id: inner.next_id, - timestamp: Local::now(), - level, - target: target.to_string(), - message: sanitized, - }; - inner.entries.push_back(entry.clone()); - while inner.entries.len() > MAX_ENTRIES { - inner.entries.pop_front(); - } - entry - } -} - -struct DebugLogger; - -impl log::Log for DebugLogger { - fn enabled(&self, metadata: &Metadata) -> bool { - metadata.level() <= LevelFilter::Trace - } - - fn log(&self, record: &Record) { - if !self.enabled(record.metadata()) { - return; - } - - // Only persist warnings and errors in the in-memory buffer. - if record.level() < Level::Warn { - return; - } - - let message = record.args().to_string(); - let entry = STORE.push(record.level(), record.target(), &message); - - if record.level() == Level::Error { - eprintln!( - "[owlen:error][{}] {}", - entry.timestamp.format("%Y-%m-%d %H:%M:%S"), - entry.message - ); - } else if record.level() == Level::Warn { - eprintln!( - "[owlen:warn][{}] {}", - entry.timestamp.format("%Y-%m-%d %H:%M:%S"), - entry.message - ); - } - } - - fn flush(&self) {} -} - -fn sanitize_message(message: &str) -> String { - static AUTH_HEADER: Lazy = - Lazy::new(|| Regex::new(r"(?i)\b(authorization)(\s*[:=]\s*)([^\r\n]+)").unwrap()); - static GENERIC_SECRET: Lazy = - Lazy::new(|| Regex::new(r"(?i)\b(api[_-]?key|token)(\s*[:=]\s*)([^,\s;]+)").unwrap()); - static BEARER_TOKEN: Lazy = - Lazy::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]+").unwrap()); - - let step = AUTH_HEADER.replace_all(message, |caps: ®ex::Captures<'_>| { - format!("{}{}", &caps[1], &caps[2]) - }); - - let step = GENERIC_SECRET.replace_all(&step, |caps: ®ex::Captures<'_>| { - format!("{}{}", &caps[1], &caps[2]) - }); - - BEARER_TOKEN - .replace_all(&step, "Bearer ") - .into_owned() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sanitize_masks_common_tokens() { - let input = - "Authorization: Bearer abc123 token=xyz456 KEY=value Authorization=Token secretStuff"; - let sanitized = sanitize_message(input); - assert!(!sanitized.contains("abc123")); - assert!(!sanitized.contains("xyz456")); - assert!(!sanitized.contains("secretStuff")); - assert_eq!(sanitized, "Authorization: "); - } - - #[test] - fn ring_buffer_discards_old_entries() { - install_global_logger(); - let initial_latest = STORE.latest_id(); - for idx in 0..(MAX_ENTRIES as u64 + 10) { - let message = format!("warn #{idx}"); - STORE.push(Level::Warn, "test", &message); - } - - let entries = STORE.snapshot(); - assert_eq!(entries.len(), MAX_ENTRIES); - assert!(entries.first().unwrap().id > initial_latest); - } -} diff --git a/crates/owlen-tui/src/state/file_icons.rs b/crates/owlen-tui/src/state/file_icons.rs deleted file mode 100644 index c0bd4ad..0000000 --- a/crates/owlen-tui/src/state/file_icons.rs +++ /dev/null @@ -1,320 +0,0 @@ -use std::env; -use std::path::Path; - -use owlen_core::config::IconMode; -use unicode_width::UnicodeWidthChar; - -use super::FileNode; - -const ENV_ICON_OVERRIDE: &str = "OWLEN_TUI_ICONS"; - -/// Concrete icon sets that can be rendered in the terminal. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FileIconSet { - Nerd, - Ascii, -} - -impl FileIconSet { - pub fn label(self) -> &'static str { - match self { - FileIconSet::Nerd => "Nerd", - FileIconSet::Ascii => "ASCII", - } - } -} - -/// How the icon mode was decided. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IconDetection { - /// Explicit configuration (config file or CLI flag) forced the mode. - Configured, - /// The runtime environment variable override selected the mode. - Environment, - /// Automatic heuristics guessed the appropriate mode. - Heuristic, -} - -/// Resolves per-file icons with configurable fallbacks. -#[derive(Debug, Clone)] -pub struct FileIconResolver { - set: FileIconSet, - detection: IconDetection, -} - -impl FileIconResolver { - /// Construct a resolver from the configured icon preference. - pub fn from_mode(pref: IconMode) -> Self { - let (set, detection) = match pref { - IconMode::Ascii => (FileIconSet::Ascii, IconDetection::Configured), - IconMode::Nerd => (FileIconSet::Nerd, IconDetection::Configured), - IconMode::Auto => detect_icon_set(), - }; - Self { set, detection } - } - - /// Effective icon set that will be rendered. - pub fn set(&self) -> FileIconSet { - self.set - } - - /// How the icon set was chosen. - pub fn detection(&self) -> IconDetection { - self.detection - } - - /// Human readable label for status lines. - pub fn status_label(&self) -> &'static str { - self.set.label() - } - - /// Short label indicating where the decision originated. - pub fn detection_label(&self) -> &'static str { - match self.detection { - IconDetection::Configured => "config", - IconDetection::Environment => "env", - IconDetection::Heuristic => "auto", - } - } - - /// Select the glyph to render for the given node. - pub fn icon_for(&self, node: &FileNode) -> &'static str { - match self.set { - FileIconSet::Nerd => nerd_icon_for(node), - FileIconSet::Ascii => ascii_icon_for(node), - } - } -} - -fn detect_icon_set() -> (FileIconSet, IconDetection) { - if let Some(set) = env_icon_override() { - return (set, IconDetection::Environment); - } - - if !locale_supports_unicode() || is_basic_terminal() { - return (FileIconSet::Ascii, IconDetection::Heuristic); - } - - if nerd_glyph_has_compact_width() { - (FileIconSet::Nerd, IconDetection::Heuristic) - } else { - (FileIconSet::Ascii, IconDetection::Heuristic) - } -} - -fn env_icon_override() -> Option { - let value = env::var(ENV_ICON_OVERRIDE).ok()?; - match value.trim().to_ascii_lowercase().as_str() { - "nerd" | "nerdfont" | "nf" | "fancy" => Some(FileIconSet::Nerd), - "ascii" | "plain" | "simple" => Some(FileIconSet::Ascii), - _ => None, - } -} - -fn locale_supports_unicode() -> bool { - let vars = ["LC_ALL", "LC_CTYPE", "LANG"]; - vars.iter() - .filter_map(|name| env::var(name).ok()) - .map(|value| value.to_ascii_lowercase()) - .any(|value| value.contains("utf-8") || value.contains("utf8")) -} - -fn is_basic_terminal() -> bool { - matches!(env::var("TERM").ok().as_deref(), Some("linux" | "vt100")) -} - -fn nerd_glyph_has_compact_width() -> bool { - // Sample glyphs chosen from the Nerd Font private use area. - const SAMPLE_ICONS: [&str; 3] = ["󰈙", "󰉋", ""]; - SAMPLE_ICONS.iter().all(|icon| { - icon.chars() - .all(|ch| UnicodeWidthChar::width(ch).unwrap_or(1) == 1) - }) -} - -fn nerd_icon_for(node: &FileNode) -> &'static str { - if node.depth == 0 { - return "󰉖"; - } - if node.is_dir { - return if node.is_expanded { "󰝰" } else { "󰉋" }; - } - - let name = node.name.as_str(); - if let Some(icon) = nerd_icon_by_special_name(name) { - return icon; - } - - let ext = Path::new(name) - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - - match ext.as_str() { - "rs" => "", - "toml" => "", - "lock" => "󰌾", - "json" => "", - "yaml" | "yml" => "", - "md" | "markdown" => "󰍔", - "py" => "", - "rb" => "", - "go" => "", - "sh" | "bash" => "", - "zsh" => "", - "fish" => "", - "ts" => "", - "tsx" => "", - "js" => "", - "jsx" => "", - "mjs" | "cjs" => "", - "html" | "htm" => "", - "css" => "", - "scss" | "sass" => "", - "less" => "", - "vue" => "󰡄", - "svelte" => "󱄄", - "java" => "", - "kt" => "󱈙", - "swift" => "", - "c" => "", - "h" => "󰙱", - "cpp" | "cxx" | "cc" => "", - "hpp" | "hh" | "hxx" => "󰙲", - "cs" => "󰌛", - "php" => "", - "zig" => "", - "lua" => "", - "sql" => "", - "erl" | "hrl" => "", - "ex" | "exs" => "", - "hs" => "", - "scala" => "", - "dart" => "", - "gradle" => "", - "groovy" => "", - "xml" => "󰗀", - "ini" | "cfg" => "", - "env" => "", - "log" => "󰌱", - "txt" => "󰈙", - "pdf" => "", - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "󰋩", - "svg" => "󰜡", - "ico" => "󰞏", - "lockb" => "󰌾", - "wasm" => "", - _ => "󰈙", - } -} - -fn nerd_icon_by_special_name(name: &str) -> Option<&'static str> { - match name { - "Cargo.toml" => Some("󰓾"), - "Cargo.lock" => Some("󰌾"), - "Makefile" | "makefile" => Some(""), - "Dockerfile" => Some("󰡨"), - ".gitignore" => Some(""), - ".gitmodules" => Some(""), - "README.md" | "readme.md" => Some("󰍔"), - "LICENSE" | "LICENSE.md" | "LICENSE.txt" => Some(""), - "package.json" => Some(""), - "package-lock.json" => Some(""), - "yarn.lock" => Some(""), - "pnpm-lock.yaml" | "pnpm-lock.yml" => Some(""), - "tsconfig.json" => Some(""), - "config.toml" => Some(""), - _ => None, - } -} - -fn ascii_icon_for(node: &FileNode) -> &'static str { - if node.depth == 0 { - return "[]"; - } - if node.is_dir { - return if node.is_expanded { "[]" } else { "<>" }; - } - - let name = node.name.as_str(); - if let Some(icon) = ascii_icon_by_special_name(name) { - return icon; - } - - let ext = Path::new(name) - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - - match ext.as_str() { - "rs" => "RS", - "toml" => "TL", - "lock" => "LK", - "json" => "JS", - "yaml" | "yml" => "YM", - "md" | "markdown" => "MD", - "py" => "PY", - "rb" => "RB", - "go" => "GO", - "sh" | "bash" | "zsh" | "fish" => "SH", - "ts" => "TS", - "tsx" => "TX", - "js" | "jsx" | "mjs" | "cjs" => "JS", - "html" | "htm" => "HT", - "css" => "CS", - "scss" | "sass" => "SC", - "vue" => "VU", - "svelte" => "SV", - "java" => "JV", - "kt" => "KT", - "swift" => "SW", - "c" => "C", - "h" => "H", - "cpp" | "cxx" | "cc" => "C+", - "hpp" | "hh" | "hxx" => "H+", - "cs" => "CS", - "php" => "PH", - "zig" => "ZG", - "lua" => "LU", - "sql" => "SQ", - "erl" | "hrl" => "ER", - "ex" | "exs" => "EX", - "hs" => "HS", - "scala" => "SC", - "dart" => "DT", - "gradle" => "GR", - "groovy" => "GR", - "xml" => "XM", - "ini" | "cfg" => "CF", - "env" => "EV", - "log" => "LG", - "txt" => "--", - "pdf" => "PD", - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "IM", - "svg" => "SG", - "wasm" => "WM", - _ => "--", - } -} - -fn ascii_icon_by_special_name(name: &str) -> Option<&'static str> { - match name { - "Cargo.toml" => Some("TL"), - "Cargo.lock" => Some("LK"), - "Makefile" | "makefile" => Some("MK"), - "Dockerfile" => Some("DK"), - ".gitignore" => Some("GI"), - ".gitmodules" => Some("GI"), - "README.md" | "readme.md" => Some("MD"), - "LICENSE" | "LICENSE.md" | "LICENSE.txt" => Some("LC"), - "package.json" => Some("PJ"), - "package-lock.json" => Some("PL"), - "yarn.lock" => Some("YL"), - "pnpm-lock.yaml" | "pnpm-lock.yml" => Some("PL"), - "tsconfig.json" => Some("TC"), - "config.toml" => Some("CF"), - _ => None, - } -} diff --git a/crates/owlen-tui/src/state/file_tree.rs b/crates/owlen-tui/src/state/file_tree.rs deleted file mode 100644 index 97afb3c..0000000 --- a/crates/owlen-tui/src/state/file_tree.rs +++ /dev/null @@ -1,722 +0,0 @@ -use crate::commands; -use anyhow::{Context, Result}; -use globset::{Glob, GlobBuilder, GlobSetBuilder}; -use ignore::WalkBuilder; -use pathdiff::diff_paths; -use std::collections::HashMap; -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// Indicates which matching strategy is applied when filtering the file tree. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FilterMode { - Glob, - Fuzzy, -} - -/// Git-related decorations rendered alongside a file entry. -#[derive(Debug, Clone)] -pub struct GitDecoration { - pub badge: Option, - pub cleanliness: char, -} - -impl GitDecoration { - pub fn clean() -> Self { - Self { - badge: None, - cleanliness: '✓', - } - } - - pub fn staged(badge: Option) -> Self { - Self { - badge, - cleanliness: '○', - } - } - - pub fn dirty(badge: Option) -> Self { - Self { - badge, - cleanliness: '●', - } - } -} - -/// Node representing a single entry (file or directory) in the tree. -#[derive(Debug, Clone)] -pub struct FileNode { - pub name: String, - pub path: PathBuf, - pub parent: Option, - pub children: Vec, - pub depth: usize, - pub is_dir: bool, - pub is_expanded: bool, - pub is_hidden: bool, - pub git: GitDecoration, -} - -impl FileNode { - fn should_default_expand(&self) -> bool { - self.depth < 2 - } -} - -/// Visible entry metadata returned to the renderer. -#[derive(Debug, Clone)] -pub struct VisibleFileEntry { - pub index: usize, - pub depth: usize, -} - -/// Tracks the entire file tree state including filters, selection, and scroll. -#[derive(Debug, Clone)] -pub struct FileTreeState { - root: PathBuf, - repo_name: String, - nodes: Vec, - visible: Vec, - cursor: usize, - scroll_top: usize, - viewport_height: usize, - filter_mode: FilterMode, - filter_query: String, - show_hidden: bool, - filter_matches: Vec, - last_error: Option, - git_branch: Option, -} - -impl FileTreeState { - /// Construct a new file tree rooted at the provided path. - pub fn new(root: impl Into) -> Self { - let mut root_path = root.into(); - if let Ok(canonical) = root_path.canonicalize() { - root_path = canonical; - } - let repo_name = root_path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| root_path.display().to_string()); - - let mut state = Self { - root: root_path, - repo_name, - nodes: Vec::new(), - visible: Vec::new(), - cursor: 0, - scroll_top: 0, - viewport_height: 20, - filter_mode: FilterMode::Glob, - filter_query: String::new(), - show_hidden: false, - filter_matches: Vec::new(), - last_error: None, - git_branch: None, - }; - - if let Err(err) = state.refresh() { - state.nodes.clear(); - state.visible.clear(); - state.filter_matches.clear(); - state.last_error = Some(err.to_string()); - } - - state - } - - /// Rebuild the file tree from disk and recompute visibility. - pub fn refresh(&mut self) -> Result<()> { - let git_map = collect_git_status(&self.root).unwrap_or_default(); - self.nodes = build_nodes(&self.root, self.show_hidden, git_map)?; - self.git_branch = current_git_branch(&self.root).unwrap_or(None); - if self.nodes.is_empty() { - self.visible.clear(); - self.filter_matches.clear(); - self.cursor = 0; - return Ok(()); - } - self.ensure_valid_cursor(); - self.recompute_filter_cache(); - self.rebuild_visible(); - Ok(()) - } - - pub fn repo_name(&self) -> &str { - &self.repo_name - } - - pub fn root(&self) -> &Path { - &self.root - } - - pub fn is_empty(&self) -> bool { - self.visible.is_empty() - } - - pub fn visible_entries(&self) -> &[VisibleFileEntry] { - &self.visible - } - - pub fn nodes(&self) -> &[FileNode] { - &self.nodes - } - - pub fn selected_index(&self) -> Option { - self.visible.get(self.cursor).map(|entry| entry.index) - } - - pub fn selected_node(&self) -> Option<&FileNode> { - self.selected_index().and_then(|idx| self.nodes.get(idx)) - } - - pub fn selected_node_mut(&mut self) -> Option<&mut FileNode> { - let idx = self.selected_index()?; - self.nodes.get_mut(idx) - } - - pub fn cursor(&self) -> usize { - self.cursor - } - - pub fn scroll_top(&self) -> usize { - self.scroll_top - } - - pub fn viewport_height(&self) -> usize { - self.viewport_height - } - - pub fn filter_mode(&self) -> FilterMode { - self.filter_mode - } - - pub fn filter_query(&self) -> &str { - &self.filter_query - } - - pub fn set_filter_mode(&mut self, mode: FilterMode) { - if self.filter_mode != mode { - self.filter_mode = mode; - self.recompute_filter_cache(); - self.rebuild_visible(); - } - } - - pub fn show_hidden(&self) -> bool { - self.show_hidden - } - - pub fn git_branch(&self) -> Option<&str> { - self.git_branch.as_deref() - } - - pub fn last_error(&self) -> Option<&str> { - self.last_error.as_deref() - } - - pub fn set_viewport_height(&mut self, height: usize) { - self.viewport_height = height.max(1); - self.ensure_cursor_in_view(); - } - - pub fn move_cursor(&mut self, delta: isize) { - if self.visible.is_empty() { - self.cursor = 0; - self.scroll_top = 0; - return; - } - - let len = self.visible.len() as isize; - let new_cursor = (self.cursor as isize + delta).clamp(0, len - 1) as usize; - self.cursor = new_cursor; - self.ensure_cursor_in_view(); - } - - pub fn jump_to_top(&mut self) { - if !self.visible.is_empty() { - self.cursor = 0; - self.scroll_top = 0; - } - } - - pub fn jump_to_bottom(&mut self) { - if !self.visible.is_empty() { - self.cursor = self.visible.len().saturating_sub(1); - let viewport = self.viewport_height.max(1); - self.scroll_top = self.visible.len().saturating_sub(viewport); - } - } - - pub fn page_down(&mut self) { - let amount = self.viewport_height.max(1) as isize; - self.move_cursor(amount); - } - - pub fn page_up(&mut self) { - let amount = -(self.viewport_height.max(1) as isize); - self.move_cursor(amount); - } - - pub fn toggle_expand(&mut self) { - if let Some(node) = self.selected_node_mut() { - if !node.is_dir { - return; - } - node.is_expanded = !node.is_expanded; - self.rebuild_visible(); - } - } - - pub fn set_filter_query(&mut self, query: impl Into) { - self.filter_query = query.into(); - self.recompute_filter_cache(); - self.rebuild_visible(); - } - - pub fn clear_filter(&mut self) { - self.filter_query.clear(); - self.recompute_filter_cache(); - self.rebuild_visible(); - } - - pub fn toggle_filter_mode(&mut self) { - let next = match self.filter_mode { - FilterMode::Glob => FilterMode::Fuzzy, - FilterMode::Fuzzy => FilterMode::Glob, - }; - self.set_filter_mode(next); - } - - pub fn toggle_hidden(&mut self) -> Result<()> { - self.show_hidden = !self.show_hidden; - self.refresh() - } - - /// Expand directories along the provided path and position the cursor. - pub fn reveal(&mut self, path: &Path) { - if self.nodes.is_empty() { - return; - } - - if let Some(rel) = diff_paths(path, &self.root) - && let Some(index) = self - .nodes - .iter() - .position(|node| node.path == rel || node.path == path) - { - self.expand_to(index); - if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) - { - self.cursor = cursor_pos; - self.ensure_cursor_in_view(); - } - } - } - - fn expand_to(&mut self, index: usize) { - let mut current = Some(index); - while let Some(idx) = current { - if let Some(parent) = self.nodes.get(idx).and_then(|node| node.parent) { - if let Some(parent_node) = self.nodes.get_mut(parent) { - parent_node.is_expanded = true; - } - current = Some(parent); - } else { - current = None; - } - } - self.rebuild_visible(); - } - - fn ensure_valid_cursor(&mut self) { - if self.cursor >= self.visible.len() { - self.cursor = self.visible.len().saturating_sub(1); - } - } - - fn ensure_cursor_in_view(&mut self) { - if self.visible.is_empty() { - self.cursor = 0; - self.scroll_top = 0; - return; - } - - let viewport = self.viewport_height.max(1); - if self.cursor < self.scroll_top { - self.scroll_top = self.cursor; - } else if self.cursor >= self.scroll_top + viewport { - self.scroll_top = self.cursor + 1 - viewport; - } - } - - fn recompute_filter_cache(&mut self) { - let has_filter = !self.filter_query.trim().is_empty(); - self.filter_matches = if !has_filter { - vec![true; self.nodes.len()] - } else { - self.nodes - .iter() - .map(|node| match self.filter_mode { - FilterMode::Glob => glob_matches(self.filter_query.trim(), node), - FilterMode::Fuzzy => fuzzy_matches(self.filter_query.trim(), node), - }) - .collect() - }; - - if has_filter { - // Ensure parent directories of matches are preserved. - for idx in (0..self.nodes.len()).rev() { - let children = self.nodes[idx].children.clone(); - if !self.filter_matches[idx] - && children - .iter() - .any(|child| self.filter_matches.get(*child).copied().unwrap_or(false)) - { - self.filter_matches[idx] = true; - } - } - } - } - - fn rebuild_visible(&mut self) { - self.visible.clear(); - - if self.nodes.is_empty() { - self.cursor = 0; - self.scroll_top = 0; - return; - } - - let has_filter = !self.filter_query.trim().is_empty(); - self.walk_visible(0, has_filter); - if self.visible.is_empty() { - // At minimum show the root node. - self.visible.push(VisibleFileEntry { - index: 0, - depth: self.nodes[0].depth, - }); - } - let max_index = self.visible.len().saturating_sub(1); - self.cursor = self.cursor.min(max_index); - self.ensure_cursor_in_view(); - } - - fn walk_visible(&mut self, index: usize, filter_override: bool) { - if !self.filter_matches.get(index).copied().unwrap_or(true) { - return; - } - - let (depth, descend, children) = { - let node = match self.nodes.get(index) { - Some(node) => node, - None => return, - }; - let descend = if filter_override { - node.is_dir - } else { - node.is_dir && node.is_expanded - }; - let children = if node.is_dir { - node.children.clone() - } else { - Vec::new() - }; - (node.depth, descend, children) - }; - - self.visible.push(VisibleFileEntry { index, depth }); - - if descend { - for child in children { - self.walk_visible(child, filter_override); - } - } - } -} - -fn glob_matches(pattern: &str, node: &FileNode) -> bool { - if pattern.is_empty() { - return true; - } - - let mut builder = GlobSetBuilder::new(); - match GlobBuilder::new(pattern).literal_separator(true).build() { - Ok(glob) => { - builder.add(glob); - if let Ok(set) = builder.build() { - return set.is_match(&node.path) || set.is_match(node.name.as_str()); - } - } - Err(_) => { - if let Ok(glob) = Glob::new("**") { - builder.add(glob); - if let Ok(set) = builder.build() { - return set.is_match(&node.path); - } - } - } - } - - false -} - -fn fuzzy_matches(query: &str, node: &FileNode) -> bool { - if query.is_empty() { - return true; - } - - let path_str = node.path.to_string_lossy(); - let name = node.name.as_str(); - - commands::match_score(&path_str, query) - .or_else(|| commands::match_score(name, query)) - .is_some() -} - -fn build_nodes( - root: &Path, - show_hidden: bool, - git_map: HashMap, -) -> Result> { - let mut builder = WalkBuilder::new(root); - builder.hidden(!show_hidden); - builder.git_global(true); - builder.git_ignore(true); - builder.git_exclude(true); - builder.follow_links(false); - builder.sort_by_file_path(|a, b| a.file_name().cmp(&b.file_name())); - - let owlen_ignore = root.join(".owlenignore"); - if owlen_ignore.exists() { - builder.add_ignore(&owlen_ignore); - } - - let mut nodes: Vec = Vec::new(); - let mut index_by_path: HashMap = HashMap::new(); - - for result in builder.build() { - let entry = match result { - Ok(value) => value, - Err(err) => { - eprintln!("File tree walk error: {err}"); - continue; - } - }; - - // Skip errors or entries without metadata. - let file_type = match entry.file_type() { - Some(ft) => ft, - None => continue, - }; - - let depth = entry.depth(); - if depth == 0 && !file_type.is_dir() { - continue; - } - - let relative = if depth == 0 { - PathBuf::new() - } else { - diff_paths(entry.path(), root).unwrap_or_else(|| entry.path().to_path_buf()) - }; - - let name = if depth == 0 { - root.file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| root.display().to_string()) - } else { - entry.file_name().to_string_lossy().into_owned() - }; - - let parent = if depth == 0 { - None - } else { - entry - .path() - .parent() - .and_then(|parent| diff_paths(parent, root)) - .and_then(|rel_parent| index_by_path.get(&rel_parent).copied()) - }; - - let git = git_map - .get(&relative) - .cloned() - .unwrap_or_else(GitDecoration::clean); - - let mut node = FileNode { - name, - path: relative.clone(), - parent, - children: Vec::new(), - depth, - is_dir: file_type.is_dir(), - is_expanded: false, - is_hidden: is_hidden(entry.file_name()), - git, - }; - - node.is_expanded = node.should_default_expand(); - - let index = nodes.len(); - if let Some(parent_idx) = parent - && let Some(parent_node) = nodes.get_mut(parent_idx) - { - parent_node.children.push(index); - } - - index_by_path.insert(relative, index); - nodes.push(node); - } - - propagate_directory_git_state(&mut nodes); - Ok(nodes) -} - -fn is_hidden(name: &OsStr) -> bool { - name.to_string_lossy().starts_with('.') -} - -fn propagate_directory_git_state(nodes: &mut [FileNode]) { - for idx in (0..nodes.len()).rev() { - if !nodes[idx].is_dir { - continue; - } - let mut has_dirty = false; - let mut dirty_badge: Option = None; - let mut has_staged = false; - for child in nodes[idx].children.clone() { - if let Some(child_node) = nodes.get(child) { - match child_node.git.cleanliness { - '●' => { - has_dirty = true; - let candidate = child_node.git.badge.unwrap_or('M'); - dirty_badge = Some(match (dirty_badge, candidate) { - (Some('D'), _) | (_, 'D') => 'D', - (Some('U'), _) | (_, 'U') => 'U', - (Some(existing), _) => existing, - (None, new_badge) => new_badge, - }); - } - '○' => { - has_staged = true; - } - _ => {} - } - } - } - - nodes[idx].git = if has_dirty { - GitDecoration::dirty(dirty_badge) - } else if has_staged { - GitDecoration::staged(None) - } else { - GitDecoration::clean() - }; - } -} - -fn collect_git_status(root: &Path) -> Result> { - if !root.join(".git").exists() { - return Ok(HashMap::new()); - } - - let output = Command::new("git") - .arg("-C") - .arg(root) - .arg("status") - .arg("--porcelain") - .output() - .with_context(|| format!("Failed to run git status in {}", root.display()))?; - - if !output.status.success() { - return Ok(HashMap::new()); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut map = HashMap::new(); - - for line in stdout.lines() { - if line.len() < 3 { - continue; - } - - let mut chars = line.chars(); - let x = chars.next().unwrap_or(' '); - let y = chars.next().unwrap_or(' '); - if x == '!' || y == '!' { - // ignored entry - continue; - } - - let mut path_part = line[3..].trim(); - if let Some(idx) = path_part.rfind(" -> ") { - path_part = &path_part[idx + 4..]; - } - - let path = PathBuf::from(path_part); - - if let Some(decoration) = decode_git_status(x, y) { - map.insert(path, decoration); - } - } - - Ok(map) -} - -fn current_git_branch(root: &Path) -> Result> { - if !root.join(".git").exists() { - return Ok(None); - } - - let output = Command::new("git") - .arg("-C") - .arg(root) - .arg("rev-parse") - .arg("--abbrev-ref") - .arg("HEAD") - .output() - .with_context(|| format!("Failed to query git branch in {}", root.display()))?; - - if !output.status.success() { - return Ok(None); - } - - let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if branch.is_empty() { - Ok(None) - } else { - Ok(Some(branch)) - } -} - -fn decode_git_status(x: char, y: char) -> Option { - if x == ' ' && y == ' ' { - return Some(GitDecoration::clean()); - } - - if x == '?' && y == '?' { - return Some(GitDecoration::dirty(Some('A'))); - } - - let badge = match (x, y) { - ('M', _) | (_, 'M') => Some('M'), - ('A', _) | (_, 'A') => Some('A'), - ('D', _) | (_, 'D') => Some('D'), - ('R', _) | (_, 'R') => Some('R'), - ('C', _) | (_, 'C') => Some('A'), - ('U', _) | (_, 'U') => Some('U'), - _ => None, - }; - - if y != ' ' { - Some(GitDecoration::dirty(badge)) - } else if x != ' ' { - Some(GitDecoration::staged(badge)) - } else { - Some(GitDecoration::clean()) - } -} diff --git a/crates/owlen-tui/src/state/keymap.rs b/crates/owlen-tui/src/state/keymap.rs deleted file mode 100644 index e6ce9a2..0000000 --- a/crates/owlen-tui/src/state/keymap.rs +++ /dev/null @@ -1,850 +0,0 @@ -use std::{ - collections::HashMap, - fs, - path::{Path, PathBuf}, - time::{Duration, Instant}, -}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use log::warn; -use owlen_core::{config::default_config_path, ui::InputMode}; -use serde::Deserialize; - -use crate::commands::registry::{AppCommand, CommandRegistry}; - -const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml"); -const EMACS_KEYMAP: &str = include_str!("../../keymap_emacs.toml"); -const DEFAULT_SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1200); -const DEFAULT_LEADER_KEY: &str = "Space"; - -#[derive(Debug, Clone)] -pub struct KeymapOverrides { - leader: String, -} - -impl KeymapOverrides { - pub fn new(leader: impl Into) -> Self { - let raw = leader.into(); - let trimmed = raw.trim(); - let leader = if trimmed.is_empty() { - DEFAULT_LEADER_KEY.to_string() - } else { - trimmed.to_string() - }; - Self { leader } - } - - pub fn leader(&self) -> &str { - &self.leader - } -} - -impl Default for KeymapOverrides { - fn default() -> Self { - Self { - leader: DEFAULT_LEADER_KEY.to_string(), - } - } -} - -#[derive(Debug, Clone)] -pub struct Keymap { - profile: KeymapProfile, - trees: HashMap, - default_timeout: Duration, - bindings: Vec, - overrides: KeymapOverrides, -} - -#[derive(Debug, Default, Clone)] -struct KeymapNode { - command: Option, - timeout: Option, - children: HashMap, -} - -#[derive(Debug, Clone)] -struct ResolvedBinding { - mode: InputMode, - sequence: Vec, - command_name: String, -} - -impl Keymap { - pub fn load( - custom_path: Option<&str>, - preferred_profile: Option<&str>, - registry: &CommandRegistry, - overrides: KeymapOverrides, - ) -> Self { - let mut loader = KeymapLoader::new(preferred_profile); - if let Some(path) = custom_path.and_then(expand_path) { - if let Ok(text) = fs::read_to_string(&path) { - loader.with_explicit(text); - } else { - warn!( - "Failed to read keymap from {}. Falling back to defaults.", - path.display() - ); - } - } - - loader.try_default_path(default_config_keymap_path()); - loader.with_embedded(DEFAULT_KEYMAP.to_string()); - - let (parsed, profile) = loader.finish(); - - let mut trees: HashMap = HashMap::new(); - let mut bindings = Vec::new(); - - for entry in parsed.bindings { - let modes: Vec<_> = entry - .resolve_modes() - .into_iter() - .filter_map(|mode| parse_mode(&mode)) - .collect(); - if modes.is_empty() { - warn!( - "Unknown input modes in keymap binding for command '{}'", - entry.command - ); - continue; - } - - let command = match registry.resolve(&entry.command) { - Some(command) => command, - None => { - warn!("Unknown command '{}' in keymap binding", entry.command); - continue; - } - }; - - let sequences = entry - .resolve_sequences() - .into_iter() - .map(|sequence| apply_overrides(sequence, &overrides)) - .collect::>(); - if sequences.is_empty() { - warn!( - "No key sequence defined for command '{}' (modes: {:?})", - entry.command, modes - ); - continue; - } - - let timeout = entry.timeout_ms.map(Duration::from_millis); - - for mut sequence_tokens in sequences { - let mut sequence = Vec::new(); - let mut parse_failed = false; - for token in sequence_tokens.drain(..) { - match KeyPattern::from_str(&token) { - Some(pattern) => sequence.push(pattern), - None => { - warn!( - "Unrecognised key specification '{}' for command '{}'", - token, entry.command - ); - parse_failed = true; - break; - } - } - } - if parse_failed || sequence.is_empty() { - continue; - } - - for mode in &modes { - let tree = trees.entry(*mode).or_default(); - insert_sequence(tree, &sequence, command, timeout); - bindings.push(ResolvedBinding { - mode: *mode, - sequence: sequence.clone(), - command_name: entry.command.clone(), - }); - } - } - } - - Self { - profile, - trees, - default_timeout: DEFAULT_SEQUENCE_TIMEOUT, - bindings, - overrides, - } - } - - pub fn leader(&self) -> &str { - self.overrides.leader() - } - - pub fn profile(&self) -> KeymapProfile { - self.profile - } - - pub fn describe_bindings(&self) -> Vec { - let mut descriptions: Vec<_> = self - .bindings - .iter() - .map(|binding| KeymapBindingDescription { - mode: binding.mode, - sequence: binding - .sequence - .iter() - .map(|pattern| pattern.display_token()) - .collect(), - command: binding.command_name.clone(), - }) - .collect(); - descriptions.sort_by(|a, b| { - let mode_cmp = format!("{:?}", a.mode).cmp(&format!("{:?}", b.mode)); - if mode_cmp != std::cmp::Ordering::Equal { - return mode_cmp; - } - let seq_a = a.sequence.join(" "); - let seq_b = b.sequence.join(" "); - let seq_cmp = seq_a.cmp(&seq_b); - if seq_cmp != std::cmp::Ordering::Equal { - return seq_cmp; - } - a.command.cmp(&b.command) - }); - descriptions - } - - pub fn step( - &self, - mode: InputMode, - event: &KeyEvent, - state: &mut KeymapState, - ) -> KeymapEventResult { - let pattern = match KeyPattern::from_event(event) { - Some(pattern) => pattern, - None => { - state.reset(); - return KeymapEventResult::NoMatch; - } - }; - - let now = Instant::now(); - state.expire_if_needed(now); - - state.sequence.push(pattern); - let mut node = self.node_for_sequence(mode, &state.sequence); - - if node.is_none() { - state.reset(); - state.sequence.push(pattern); - node = self.node_for_sequence(mode, &state.sequence); - if node.is_none() { - state.reset(); - return KeymapEventResult::NoMatch; - } - } - - let node = node.unwrap(); - let timeout = node.timeout.unwrap_or(self.default_timeout); - - if let Some(command) = node.command { - state.reset(); - return KeymapEventResult::Matched(command); - } - - if node.children.is_empty() { - state.reset(); - return KeymapEventResult::NoMatch; - } - - state.deadline = Some(now + timeout); - KeymapEventResult::Pending - } - - fn node_for_sequence(&self, mode: InputMode, sequence: &[KeyPattern]) -> Option<&KeymapNode> { - let mut node = self.trees.get(&mode)?; - for pattern in sequence { - node = node.children.get(pattern)?; - } - Some(node) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KeymapEventResult { - Matched(AppCommand), - Pending, - NoMatch, -} - -#[derive(Default, Debug)] -pub struct KeymapState { - sequence: Vec, - deadline: Option, -} - -impl KeymapState { - pub fn reset(&mut self) { - self.sequence.clear(); - self.deadline = None; - } - - fn expire_if_needed(&mut self, now: Instant) { - if let Some(deadline) = self.deadline - && now > deadline - { - self.reset(); - } - } - - pub fn sequence_tokens(&self) -> Vec { - self.sequence - .iter() - .map(|pattern| pattern.display_token()) - .collect() - } - - pub fn matches_sequence(&self, tokens: &[&str]) -> bool { - if self.sequence.len() != tokens.len() { - return false; - } - tokens.iter().enumerate().all(|(idx, token)| { - KeyPattern::from_str(token) - .map(|pattern| pattern == self.sequence[idx]) - .unwrap_or(false) - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct KeyPattern { - code: KeyCodeKind, - modifiers: KeyModifiers, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum KeyCodeKind { - Char(char), - Enter, - Tab, - BackTab, - Backspace, - Esc, - Up, - Down, - Left, - Right, - PageUp, - PageDown, - Home, - End, - F(u8), -} - -impl KeyPattern { - fn from_event(event: &KeyEvent) -> Option { - let code = match event.code { - KeyCode::Char(c) => KeyCodeKind::Char(c), - KeyCode::Enter => KeyCodeKind::Enter, - KeyCode::Tab => KeyCodeKind::Tab, - KeyCode::BackTab => KeyCodeKind::BackTab, - KeyCode::Backspace => KeyCodeKind::Backspace, - KeyCode::Esc => KeyCodeKind::Esc, - KeyCode::Up => KeyCodeKind::Up, - KeyCode::Down => KeyCodeKind::Down, - KeyCode::Left => KeyCodeKind::Left, - KeyCode::Right => KeyCodeKind::Right, - KeyCode::PageUp => KeyCodeKind::PageUp, - KeyCode::PageDown => KeyCodeKind::PageDown, - KeyCode::Home => KeyCodeKind::Home, - KeyCode::End => KeyCodeKind::End, - KeyCode::F(n) => KeyCodeKind::F(n), - _ => return None, - }; - - Some(Self { - code, - modifiers: normalize_modifiers(event.modifiers), - }) - } - - fn from_str(spec: &str) -> Option { - let tokens: Vec<&str> = spec - .split('+') - .map(|token| token.trim()) - .filter(|token| !token.is_empty()) - .collect(); - - if tokens.is_empty() { - return None; - } - - let mut modifiers = KeyModifiers::empty(); - let key_token = tokens.last().copied().unwrap(); - - for token in tokens[..tokens.len().saturating_sub(1)].iter() { - match token.to_ascii_lowercase().as_str() { - "ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL), - "alt" | "option" | "meta" => modifiers.insert(KeyModifiers::ALT), - "shift" => modifiers.insert(KeyModifiers::SHIFT), - other => warn!("Unknown modifier '{other}' in key binding '{spec}'"), - } - } - - let code = parse_key_token(key_token, &mut modifiers)?; - - Some(Self { - code, - modifiers: normalize_modifiers(modifiers), - }) - } - - fn display_token(&self) -> String { - let mut parts = Vec::new(); - if self.modifiers.contains(KeyModifiers::CONTROL) { - parts.push("Ctrl".to_string()); - } - if self.modifiers.contains(KeyModifiers::ALT) { - parts.push("Alt".to_string()); - } - if self.modifiers.contains(KeyModifiers::SHIFT) { - parts.push("Shift".to_string()); - } - - let key = match self.code { - KeyCodeKind::Char(' ') => "Space".to_string(), - KeyCodeKind::Char(c) => c.to_string(), - KeyCodeKind::Enter => "Enter".to_string(), - KeyCodeKind::Tab => "Tab".to_string(), - KeyCodeKind::BackTab => "BackTab".to_string(), - KeyCodeKind::Backspace => "Backspace".to_string(), - KeyCodeKind::Esc => "Esc".to_string(), - KeyCodeKind::Up => "Up".to_string(), - KeyCodeKind::Down => "Down".to_string(), - KeyCodeKind::Left => "Left".to_string(), - KeyCodeKind::Right => "Right".to_string(), - KeyCodeKind::PageUp => "PageUp".to_string(), - KeyCodeKind::PageDown => "PageDown".to_string(), - KeyCodeKind::Home => "Home".to_string(), - KeyCodeKind::End => "End".to_string(), - KeyCodeKind::F(n) => format!("F{}", n), - }; - parts.push(key); - parts.join("+") - } -} - -#[derive(Debug, Deserialize)] -struct KeymapConfig { - #[serde(default, rename = "binding")] - bindings: Vec, -} - -#[derive(Debug, Deserialize)] -struct KeyBindingConfig { - #[serde(default)] - mode: Option, - #[serde(default)] - modes: Vec, - command: String, - #[serde(default, rename = "keys")] - keys: Option, - #[serde(default)] - sequence: Option, - #[serde(default)] - sequences: Vec, - #[serde(default)] - timeout_ms: Option, -} - -impl KeyBindingConfig { - fn resolve_modes(&self) -> Vec { - if !self.modes.is_empty() { - self.modes.clone() - } else if let Some(mode) = &self.mode { - vec![mode.clone()] - } else { - Vec::new() - } - } - - fn resolve_sequences(&self) -> Vec> { - let mut result = Vec::new(); - - if let Some(keys) = self.keys.clone() { - for key in keys.into_iter() { - result.push(vec![key]); - } - } - - if let Some(seq) = self.sequence.clone() { - result.push(seq.into_sequence()); - } - - for seq in self.sequences.clone() { - result.push(seq.into_sequence()); - } - - result - } -} - -#[derive(Debug, Deserialize, Clone)] -#[serde(untagged)] -enum SequenceSpec { - Single(String), - Sequence(Vec), -} - -impl SequenceSpec { - fn into_sequence(self) -> Vec { - match self { - SequenceSpec::Single(value) => vec![value], - SequenceSpec::Sequence(values) => values, - } - } -} - -#[derive(Debug, Deserialize, Clone)] -#[serde(untagged)] -enum KeyList { - Single(String), - Multiple(Vec), -} - -impl KeyList { - fn into_iter(self) -> Vec { - match self { - KeyList::Single(key) => vec![key], - KeyList::Multiple(keys) => keys, - } - } -} - -fn apply_overrides(sequence: Vec, overrides: &KeymapOverrides) -> Vec { - sequence - .into_iter() - .map(|token| { - if token.trim().eq_ignore_ascii_case("") { - overrides.leader().to_string() - } else { - token - } - }) - .collect() -} - -fn insert_sequence( - root: &mut KeymapNode, - sequence: &[KeyPattern], - command: AppCommand, - timeout: Option, -) { - let mut node = root; - for pattern in sequence.iter() { - let child = node.children.entry(*pattern).or_default(); - if let Some(duration) = timeout { - child.timeout = match child.timeout { - Some(existing) if existing <= duration => Some(existing), - _ => Some(duration), - }; - } - node = child; - } - - if let Some(existing) = node.command { - if existing != command { - warn!( - "Keymap conflict: multiple commands mapped to sequence {:?}", - sequence - .iter() - .map(|pattern| pattern.display_token()) - .collect::>() - ); - } - } else { - node.command = Some(command); - } -} - -fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option { - let token_lower = token.to_ascii_lowercase(); - let code = match token_lower.as_str() { - "enter" | "return" => KeyCodeKind::Enter, - "tab" => { - if modifiers.contains(KeyModifiers::SHIFT) { - modifiers.remove(KeyModifiers::SHIFT); - KeyCodeKind::BackTab - } else { - KeyCodeKind::Tab - } - } - "backtab" => KeyCodeKind::BackTab, - "backspace" | "bs" => KeyCodeKind::Backspace, - "esc" | "escape" => KeyCodeKind::Esc, - "up" => KeyCodeKind::Up, - "down" => KeyCodeKind::Down, - "left" => KeyCodeKind::Left, - "right" => KeyCodeKind::Right, - "pageup" | "page_up" | "pgup" => KeyCodeKind::PageUp, - "pagedown" | "page_down" | "pgdn" => KeyCodeKind::PageDown, - "home" => KeyCodeKind::Home, - "end" => KeyCodeKind::End, - token if token.starts_with('f') && token.len() > 1 => { - let num = token[1..].parse::().ok()?; - KeyCodeKind::F(num) - } - "space" => KeyCodeKind::Char(' '), - "semicolon" => KeyCodeKind::Char(';'), - "slash" => KeyCodeKind::Char('/'), - _ => { - let chars: Vec = token.chars().collect(); - if chars.len() == 1 { - KeyCodeKind::Char(chars[0]) - } else { - return None; - } - } - }; - - Some(code) -} - -fn parse_mode(mode: &str) -> Option { - match mode.to_ascii_lowercase().as_str() { - "normal" => Some(InputMode::Normal), - "editing" | "insert" => Some(InputMode::Editing), - "command" => Some(InputMode::Command), - "visual" => Some(InputMode::Visual), - "provider_selection" | "provider" => Some(InputMode::ProviderSelection), - "model_selection" | "model" => Some(InputMode::ModelSelection), - "help" => Some(InputMode::Help), - "session_browser" | "sessions" => Some(InputMode::SessionBrowser), - "theme_browser" | "themes" => Some(InputMode::ThemeBrowser), - "repo_search" | "search" => Some(InputMode::RepoSearch), - "symbol_search" | "symbols" => Some(InputMode::SymbolSearch), - _ => None, - } -} - -fn default_config_keymap_path() -> Option { - let config_path = default_config_path(); - let dir = config_path.parent()?; - Some(dir.join("keymap.toml")) -} - -fn expand_path(path: &str) -> Option { - if path.trim().is_empty() { - return None; - } - let expanded = shellexpand::tilde(path); - let candidate = Path::new(expanded.as_ref()).to_path_buf(); - Some(candidate) -} - -fn normalize_modifiers(modifiers: KeyModifiers) -> KeyModifiers { - modifiers -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum KeymapProfile { - Vim, - Emacs, - Custom, -} - -impl KeymapProfile { - pub(crate) fn from_str(input: &str) -> Option { - match input.to_ascii_lowercase().as_str() { - "vim" | "default" => Some(Self::Vim), - "emacs" => Some(Self::Emacs), - "custom" => Some(Self::Custom), - _ => None, - } - } - - fn builtin(&self) -> Option<&'static str> { - match self { - Self::Vim => Some(DEFAULT_KEYMAP), - Self::Emacs => Some(EMACS_KEYMAP), - Self::Custom => None, - } - } - - pub(crate) fn config_value(&self) -> &'static str { - match self { - Self::Vim => "vim", - Self::Emacs => "emacs", - Self::Custom => "custom", - } - } - - pub(crate) fn label(&self) -> &'static str { - match self { - Self::Vim => "Vim", - Self::Emacs => "Emacs", - Self::Custom => "Custom", - } - } - - pub(crate) fn is_builtin(&self) -> bool { - matches!(self, Self::Vim | Self::Emacs) - } -} - -struct KeymapLoader { - explicit: Option, - default_path_content: Option, - preferred_profile: Option, - active: KeymapProfile, -} - -impl KeymapLoader { - fn new(preferred_profile: Option<&str>) -> Self { - let preferred = preferred_profile.and_then(KeymapProfile::from_str); - Self { - explicit: None, - default_path_content: None, - preferred_profile: preferred, - active: preferred.unwrap_or(KeymapProfile::Vim), - } - } - - fn with_explicit(&mut self, content: String) { - self.explicit = Some(content); - self.active = KeymapProfile::Custom; - } - - fn try_default_path(&mut self, path: Option) { - if self.explicit.is_some() { - return; - } - - if let Some(path) = path - && let Ok(text) = fs::read_to_string(&path) - { - self.default_path_content = Some(text); - self.active = KeymapProfile::Custom; - } - } - - fn with_embedded(&mut self, fallback: String) { - if self.explicit.is_some() || self.default_path_content.is_some() { - return; - } - - if let Some(profile) = self.preferred_profile.and_then(|profile| profile.builtin()) { - self.explicit = Some(profile.to_string()); - self.active = self.preferred_profile.unwrap_or(KeymapProfile::Vim); - } else { - self.explicit = Some(fallback); - self.active = KeymapProfile::Vim; - } - } - - fn finish(self) -> (KeymapConfig, KeymapProfile) { - let data = self - .explicit - .or(self.default_path_content) - .unwrap_or_else(|| DEFAULT_KEYMAP.to_string()); - - match toml::from_str(&data) { - Ok(parsed) => (parsed, self.active), - Err(err) => { - warn!("Failed to parse keymap: {err}. Using built-in defaults."); - let parsed = toml::from_str(DEFAULT_KEYMAP) - .expect("embedded keymap should parse successfully"); - (parsed, KeymapProfile::Vim) - } - } - } -} - -#[derive(Debug, Clone)] -pub struct KeymapBindingDescription { - pub mode: InputMode, - pub sequence: Vec, - pub command: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - #[test] - fn resolve_binding_from_default_keymap() { - let registry = CommandRegistry::new(); - let keymap = Keymap::load(None, None, ®istry, KeymapOverrides::default()); - let mut state = KeymapState::default(); - - let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); - let result = keymap.step(InputMode::Normal, &event, &mut state); - assert!(matches!( - result, - KeymapEventResult::Matched(AppCommand::OpenModelPicker(None)) - )); - } - - #[test] - fn resolves_multi_key_sequence() { - let registry = CommandRegistry::new(); - let keymap = Keymap::load(None, None, ®istry, KeymapOverrides::default()); - let mut state = KeymapState::default(); - - let first = keymap.step( - InputMode::Normal, - &KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), - &mut state, - ); - assert!(matches!(first, KeymapEventResult::Pending)); - assert!(state.matches_sequence(&["g"])); - - let second = keymap.step( - InputMode::Normal, - &KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), - &mut state, - ); - assert!(matches!(second, KeymapEventResult::Matched(_))); - assert!(state.sequence_tokens().is_empty()); - } - - #[test] - fn emacs_profile_loads_builtin() { - let registry = CommandRegistry::new(); - let keymap = Keymap::load(None, Some("emacs"), ®istry, KeymapOverrides::default()); - assert_eq!(keymap.profile(), KeymapProfile::Emacs); - let mut state = KeymapState::default(); - let result = keymap.step( - InputMode::Normal, - &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT), - &mut state, - ); - assert!(matches!( - result, - KeymapEventResult::Matched(AppCommand::EnterCommandMode) - )); - } - - #[test] - fn leader_override_substitutes_placeholder_tokens() { - let registry = CommandRegistry::new(); - let overrides = KeymapOverrides::new("Ctrl+Space"); - let keymap = Keymap::load(None, None, ®istry, overrides.clone()); - assert_eq!(keymap.leader(), overrides.leader()); - - let mut found_leader_binding = false; - for binding in keymap.describe_bindings() { - if binding.command == "model.open_all" - && binding.sequence.first().map(String::as_str) == Some("Ctrl+Space") - { - found_leader_binding = true; - break; - } - } - - assert!( - found_leader_binding, - "expected leader substitution to produce Ctrl+Space prefix for model.open_all" - ); - } -} diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs deleted file mode 100644 index 6e03820..0000000 --- a/crates/owlen-tui/src/state/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! State helpers shared across TUI components. -//! -//! The `state` module contains lightweight wrappers that encapsulate UI state -//! shared between widgets. Keeping these helpers out of the main `chat_app` -//! implementation makes the command palette and other stateful widgets easier -//! to test in isolation. - -mod command_palette; -mod debug_log; -mod file_icons; -mod file_tree; -mod keymap; -mod search; -mod workspace; - -pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion}; -pub use debug_log::{DebugLogEntry, DebugLogState, install_global_logger}; -pub use file_icons::{FileIconResolver, FileIconSet, IconDetection}; -pub use file_tree::{ - FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, -}; -pub use keymap::{ - Keymap, KeymapBindingDescription, KeymapEventResult, KeymapOverrides, KeymapProfile, - KeymapState, -}; -pub use search::{ - RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind, - RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState, - spawn_repo_search_task, spawn_symbol_search_task, -}; -pub use workspace::{ - CodePane, CodeWorkspace, EditorTab, LayoutNode, PaneDirection, PaneId, PaneRestoreRequest, - SplitAxis, WorkspaceSnapshot, -}; diff --git a/crates/owlen-tui/src/state/search.rs b/crates/owlen-tui/src/state/search.rs deleted file mode 100644 index b6ae5dc..0000000 --- a/crates/owlen-tui/src/state/search.rs +++ /dev/null @@ -1,1057 +0,0 @@ -use crate::commands; -use anyhow::{Context, Result, anyhow}; -use ignore::WalkBuilder; -use pathdiff::diff_paths; -use serde::Deserialize; -use std::fs; -use std::path::{Path, PathBuf}; -use tokio::{ - io::{AsyncBufReadExt, AsyncReadExt, BufReader}, - process::{ChildStderr, Command}, - sync::mpsc, - task::JoinHandle, -}; -use tree_sitter::{Node, Parser, TreeCursor}; - -/// Single match returned from a repository-wide search. -#[derive(Debug, Clone)] -pub struct RepoSearchMatch { - pub line_number: u32, - pub column: u32, - pub preview: String, - pub matched: Option, -} - -/// Aggregated matches for a single file path. -#[derive(Debug, Clone)] -pub struct RepoSearchFile { - pub absolute: PathBuf, - pub display: String, - pub matches: Vec, -} - -/// Logical row rendered in the repo-search results list. -#[derive(Debug, Clone)] -pub struct RepoSearchRow { - pub file_index: usize, - pub kind: RepoSearchRowKind, -} - -/// Row variants used to render headers and match lines. -#[derive(Debug, Clone)] -pub enum RepoSearchRowKind { - FileHeader, - Match { match_index: usize }, -} - -/// UI state for the ripgrep-backed repository search overlay. -#[derive(Debug, Clone, Default)] -pub struct RepoSearchState { - query_input: String, - last_query: Option, - files: Vec, - rows: Vec, - selected_row: usize, - scroll_top: usize, - viewport_height: usize, - running: bool, - dirty: bool, - status: Option, - error: Option, -} - -impl RepoSearchState { - pub fn new() -> Self { - Self::default() - } - - pub fn reset(&mut self) { - self.query_input.clear(); - self.last_query = None; - self.files.clear(); - self.rows.clear(); - self.selected_row = 0; - self.scroll_top = 0; - self.viewport_height = 0; - self.running = false; - self.dirty = false; - self.status = None; - self.error = None; - } - - pub fn query_input(&self) -> &str { - &self.query_input - } - - pub fn set_query_input(&mut self, value: impl Into) { - self.query_input = value.into(); - self.dirty = true; - } - - pub fn push_query_char(&mut self, ch: char) { - self.query_input.push(ch); - self.dirty = true; - } - - pub fn pop_query_char(&mut self) { - self.query_input.pop(); - self.dirty = true; - } - - pub fn clear_query(&mut self) { - self.query_input.clear(); - self.last_query = None; - self.dirty = true; - } - - /// Prepare to launch a new search. Returns the trimmed query string if work should start. - pub fn prepare_run(&mut self) -> Option { - if self.running { - return None; - } - - let trimmed = self.query_input.trim(); - if trimmed.is_empty() { - return None; - } - - let normalized = trimmed.to_string(); - self.query_input = normalized.clone(); - self.last_query = Some(normalized.clone()); - self.files.clear(); - self.rows.clear(); - self.selected_row = 0; - self.scroll_top = 0; - self.running = true; - self.dirty = false; - self.status = Some(format!("Searching for \"{normalized}\"…")); - self.error = None; - Some(normalized) - } - - pub fn add_file(&mut self, absolute: PathBuf, display: String) -> usize { - let index = self.files.len(); - self.files.push(RepoSearchFile { - absolute, - display, - matches: Vec::new(), - }); - self.rows.push(RepoSearchRow { - file_index: index, - kind: RepoSearchRowKind::FileHeader, - }); - if self.rows.len() == 1 { - self.selected_row = 0; - } - index - } - - pub fn ensure_file_entry(&mut self, absolute: PathBuf, display: String) -> usize { - if let Some((idx, _)) = self - .files - .iter() - .enumerate() - .find(|(_, f)| f.absolute == absolute) - { - return idx; - } - self.add_file(absolute, display) - } - - pub fn add_match( - &mut self, - file_index: usize, - line_number: u32, - column: u32, - preview: String, - matched: Option, - ) { - if let Some(file) = self.files.get_mut(file_index) { - let match_index = file.matches.len(); - file.matches.push(RepoSearchMatch { - line_number, - column, - preview, - matched, - }); - self.rows.push(RepoSearchRow { - file_index, - kind: RepoSearchRowKind::Match { match_index }, - }); - - if matches!( - self.rows[self.selected_row].kind, - RepoSearchRowKind::FileHeader - ) - && let Some(idx) = self - .rows - .iter() - .position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. })) - { - self.selected_row = idx; - } - self.ensure_selection_visible(); - } - } - - pub fn finish(&mut self, match_count: usize) { - self.running = false; - if match_count == 0 { - self.status = Some("No matches".to_string()); - } else { - self.status = Some(format!("{match_count} match(es)")); - } - } - - pub fn mark_error(&mut self, message: impl Into) { - self.running = false; - self.error = Some(message.into()); - } - - pub fn running(&self) -> bool { - self.running - } - - pub fn dirty(&self) -> bool { - self.dirty - } - - pub fn last_query(&self) -> Option<&str> { - self.last_query.as_deref() - } - - pub fn files(&self) -> &[RepoSearchFile] { - &self.files - } - - pub fn rows(&self) -> &[RepoSearchRow] { - &self.rows - } - - pub fn status(&self) -> Option<&String> { - self.status.as_ref() - } - - pub fn status_mut(&mut self) -> &mut Option { - &mut self.status - } - - pub fn error(&self) -> Option<&String> { - self.error.as_ref() - } - - pub fn viewport_height(&self) -> usize { - self.viewport_height - } - - pub fn set_viewport_height(&mut self, height: usize) { - self.viewport_height = height; - if height == 0 { - self.scroll_top = 0; - return; - } - - let max_scroll = self.rows.len().saturating_sub(height); - if self.scroll_top > max_scroll { - self.scroll_top = max_scroll; - } - self.ensure_selection_visible(); - } - - pub fn scroll_top(&self) -> usize { - self.scroll_top - } - - pub fn move_selection(&mut self, delta: isize) { - if self.rows.is_empty() { - return; - } - - let len = self.rows.len() as isize; - let mut new_index = self.selected_row as isize + delta; - new_index = new_index.clamp(0, len - 1); - - if delta >= 0 { - while new_index < len - && matches!( - self.rows[new_index as usize].kind, - RepoSearchRowKind::FileHeader - ) - { - new_index += 1; - } - } else { - while new_index >= 0 - && matches!( - self.rows[new_index as usize].kind, - RepoSearchRowKind::FileHeader - ) - { - new_index -= 1; - } - } - - if new_index < 0 || new_index >= len { - // No match rows – stick to current selection. - return; - } - - self.selected_row = new_index as usize; - self.ensure_selection_visible(); - } - - pub fn page(&mut self, delta: isize) { - if self.viewport_height == 0 { - return; - } - let height = self.viewport_height as isize; - let new_scroll = - (self.scroll_top as isize + delta * height).clamp(0, self.max_scroll() as isize); - self.scroll_top = new_scroll as usize; - - if delta > 0 { - let target = (self.scroll_top + self.viewport_height).saturating_sub(1); - self.selected_row = self - .rows - .iter() - .enumerate() - .rev() - .find(|(idx, _)| *idx <= target) - .map(|(idx, row)| { - if matches!(row.kind, RepoSearchRowKind::FileHeader) && idx > 0 { - idx - 1 - } else { - idx - } - }) - .unwrap_or(self.selected_row); - } else if delta < 0 { - self.selected_row = self.scroll_top.min(self.rows.len().saturating_sub(1)); - } - - self.ensure_selection_visible(); - } - - pub fn selected_row_index(&self) -> usize { - self.selected_row - } - - pub fn selected_indices(&self) -> Option<(usize, usize)> { - let row = self.rows.get(self.selected_row)?; - match row.kind { - RepoSearchRowKind::Match { match_index } => Some((row.file_index, match_index)), - RepoSearchRowKind::FileHeader => None, - } - } - - pub fn selected_match(&self) -> Option<(&RepoSearchFile, &RepoSearchMatch)> { - let (file_idx, match_idx) = self.selected_indices()?; - let file = self.files.get(file_idx)?; - let m = file.matches.get(match_idx)?; - Some((file, m)) - } - - pub fn scroll_to(&mut self, offset: usize) { - self.scroll_top = offset.min(self.max_scroll()); - self.ensure_selection_visible(); - } - - pub fn visible_rows(&self) -> &[RepoSearchRow] { - let start = self.scroll_top; - let end = (start + self.viewport_height).min(self.rows.len()); - &self.rows[start..end] - } - - pub fn max_scroll(&self) -> usize { - self.rows.len().saturating_sub(self.viewport_height.max(1)) - } - - fn ensure_selection_visible(&mut self) { - if self.viewport_height == 0 { - return; - } - if self.selected_row < self.scroll_top { - self.scroll_top = self.selected_row; - } else if self.selected_row >= self.scroll_top + self.viewport_height { - self.scroll_top = self.selected_row + 1 - self.viewport_height; - } - } - - pub fn has_results(&self) -> bool { - self.files.iter().any(|file| !file.matches.is_empty()) - } -} - -/// Streamed events emitted by the ripgrep worker task. -#[derive(Debug)] -pub enum RepoSearchMessage { - File { - path: PathBuf, - }, - Match { - path: PathBuf, - line_number: u32, - column: u32, - preview: String, - matched: Option, - }, - Done { - matches: usize, - }, - Error(String), -} - -/// Launch an asynchronous ripgrep search task. -pub fn spawn_repo_search_task( - root: PathBuf, - query: String, -) -> Result<(JoinHandle<()>, mpsc::UnboundedReceiver)> { - let (tx, rx) = mpsc::unbounded_channel(); - - let handle = tokio::spawn(async move { - if let Err(err) = run_repo_search(root, query, tx.clone()).await { - let _ = tx.send(RepoSearchMessage::Error(err.to_string())); - } - }); - - Ok((handle, rx)) -} - -/// Classification of the symbol extracted from source code. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SymbolKind { - Function, - Struct, - Enum, - Trait, - Module, - TypeAlias, -} - -impl SymbolKind { - pub fn icon(self) -> &'static str { - match self { - SymbolKind::Function => "ƒ", - SymbolKind::Struct => "▣", - SymbolKind::Enum => "◇", - SymbolKind::Trait => "♦", - SymbolKind::Module => "▸", - SymbolKind::TypeAlias => "≡", - } - } - - pub fn label(self) -> &'static str { - match self { - SymbolKind::Function => "fn", - SymbolKind::Struct => "struct", - SymbolKind::Enum => "enum", - SymbolKind::Trait => "trait", - SymbolKind::Module => "mod", - SymbolKind::TypeAlias => "type", - } - } -} - -/// Single indexed symbol within the workspace. -#[derive(Debug, Clone)] -pub struct SymbolEntry { - pub name: String, - pub kind: SymbolKind, - pub file: PathBuf, - pub display_path: String, - pub line: u32, -} - -/// Interactive state for the tree-sitter-backed symbol search overlay. -#[derive(Debug, Clone, Default)] -pub struct SymbolSearchState { - query: String, - items: Vec, - filtered: Vec, - selected: usize, - scroll_top: usize, - viewport_height: usize, - running: bool, - status: Option, - error: Option, -} - -impl SymbolSearchState { - pub fn new() -> Self { - Self::default() - } - - pub fn begin_index(&mut self) { - self.items.clear(); - self.filtered.clear(); - self.selected = 0; - self.scroll_top = 0; - self.running = true; - self.status = Some("Indexing symbols…".to_string()); - self.error = None; - } - - pub fn is_running(&self) -> bool { - self.running - } - - pub fn status(&self) -> Option<&String> { - self.status.as_ref() - } - - pub fn status_mut(&mut self) -> &mut Option { - &mut self.status - } - - pub fn error(&self) -> Option<&String> { - self.error.as_ref() - } - - pub fn query(&self) -> &str { - &self.query - } - - pub fn set_query(&mut self, value: impl Into) { - self.query = value.into(); - self.refilter(); - } - - pub fn push_query_char(&mut self, ch: char) { - self.query.push(ch); - self.refilter(); - } - - pub fn pop_query_char(&mut self) { - self.query.pop(); - self.refilter(); - } - - pub fn clear_query(&mut self) { - self.query.clear(); - self.refilter(); - } - - pub fn add_symbols(&mut self, symbols: Vec) { - if symbols.is_empty() { - return; - } - let start_len = self.items.len(); - self.items.extend(symbols); - if self.filtered.len() == start_len && self.query.trim().is_empty() { - self.filtered.extend(start_len..self.items.len()); - } - self.refilter(); - } - - pub fn finish(&mut self) { - self.running = false; - if self.items.is_empty() { - self.status = Some("No symbols found".to_string()); - } else { - self.status = Some(format!("Indexed {} symbols", self.items.len())); - } - } - - pub fn mark_error(&mut self, message: impl Into) { - self.running = false; - self.error = Some(message.into()); - } - - pub fn set_viewport_height(&mut self, height: usize) { - self.viewport_height = height; - if self.viewport_height == 0 { - self.scroll_top = 0; - return; - } - let max_scroll = self.filtered.len().saturating_sub(self.viewport_height); - if self.scroll_top > max_scroll { - self.scroll_top = max_scroll; - } - self.ensure_selection_visible(); - } - - pub fn visible_indices(&self) -> &[usize] { - let start = self.scroll_top.min(self.filtered.len()); - let end = (start + self.viewport_height).min(self.filtered.len()); - &self.filtered[start..end] - } - - pub fn items(&self) -> &[SymbolEntry] { - &self.items - } - - pub fn move_selection(&mut self, delta: isize) { - if self.filtered.is_empty() { - return; - } - let len = self.filtered.len() as isize; - let mut new_index = self.selected as isize + delta; - new_index = new_index.clamp(0, len - 1); - self.selected = new_index as usize; - self.ensure_selection_visible(); - } - - pub fn page(&mut self, delta: isize) { - if self.viewport_height == 0 { - return; - } - let viewport = self.viewport_height as isize; - let new_scroll = - (self.scroll_top as isize + delta * viewport).clamp(0, self.max_scroll() as isize); - self.scroll_top = new_scroll as usize; - self.selected = self.scroll_top.min(self.filtered.len().saturating_sub(1)); - self.ensure_selection_visible(); - } - - pub fn scroll_to(&mut self, offset: usize) { - self.scroll_top = offset.min(self.max_scroll()); - self.ensure_selection_visible(); - } - - pub fn selected_entry(&self) -> Option<&SymbolEntry> { - self.filtered - .get(self.selected) - .and_then(|idx| self.items.get(*idx)) - } - - pub fn has_results(&self) -> bool { - !self.filtered.is_empty() - } - - pub fn selected_filtered_index(&self) -> Option { - self.filtered.get(self.selected).copied() - } - - pub fn scroll_top(&self) -> usize { - self.scroll_top - } - - pub fn filtered_len(&self) -> usize { - self.filtered.len() - } - - fn refilter(&mut self) { - if self.items.is_empty() { - self.filtered.clear(); - self.selected = 0; - self.scroll_top = 0; - return; - } - - let trimmed = self.query.trim(); - if trimmed.is_empty() { - self.filtered = (0..self.items.len()).collect(); - } else { - let mut scored: Vec<(usize, (usize, usize))> = Vec::new(); - for (idx, item) in self.items.iter().enumerate() { - if let Some(score) = commands::match_score(item.name.as_str(), trimmed) { - scored.push((idx, score)); - continue; - } - if let Some(score) = commands::match_score(item.display_path.as_str(), trimmed) { - scored.push((idx, score)); - } - } - scored.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); - self.filtered = scored.into_iter().map(|(idx, _)| idx).collect(); - } - - if self.selected >= self.filtered.len() { - self.selected = self.filtered.len().saturating_sub(1); - } - if self.filtered.is_empty() { - self.selected = 0; - self.scroll_top = 0; - } else { - self.ensure_selection_visible(); - } - } - - fn ensure_selection_visible(&mut self) { - if self.viewport_height == 0 || self.filtered.is_empty() { - return; - } - if self.selected < self.scroll_top { - self.scroll_top = self.selected; - } else if self.selected >= self.scroll_top + self.viewport_height { - self.scroll_top = self.selected + 1 - self.viewport_height; - } - } - - fn max_scroll(&self) -> usize { - self.filtered - .len() - .saturating_sub(self.viewport_height.max(1)) - } -} - -/// Messages emitted by the background symbol indexer. -#[derive(Debug)] -pub enum SymbolSearchMessage { - Symbols(Vec), - Done, - Error(String), -} - -pub fn spawn_symbol_search_task( - root: PathBuf, -) -> Result<(JoinHandle<()>, mpsc::UnboundedReceiver)> { - let (tx, rx) = mpsc::unbounded_channel(); - let handle = tokio::spawn(async move { - if let Err(err) = run_symbol_indexer(root, tx.clone()).await { - let _ = tx.send(SymbolSearchMessage::Error(err.to_string())); - } - }); - Ok((handle, rx)) -} - -async fn run_symbol_indexer( - root: PathBuf, - sender: mpsc::UnboundedSender, -) -> Result<()> { - tokio::task::spawn_blocking(move || -> Result<()> { - let mut parser = Parser::new(); - parser - .set_language(&tree_sitter_rust::language()) - .context("failed to initialise tree-sitter for Rust")?; - - let mut walker = WalkBuilder::new(&root); - walker.git_ignore(true); - walker.git_exclude(true); - walker.follow_links(false); - - for entry in walker.build() { - let entry = match entry { - Ok(value) => value, - Err(err) => { - eprintln!("symbol index walk error: {err}"); - continue; - } - }; - - if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { - continue; - } - - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) != Some("rs") { - continue; - } - - let source = match fs::read(path) { - Ok(bytes) => bytes, - Err(err) => { - eprintln!("symbol index read error {}: {err}", path.display()); - continue; - } - }; - - if source.is_empty() { - continue; - } - - let tree = match parser.parse(&source, None) { - Some(tree) => tree, - None => continue, - }; - - let mut symbols = Vec::new(); - collect_rust_symbols(&tree, &source, path, &root, &mut symbols); - if !symbols.is_empty() && sender.send(SymbolSearchMessage::Symbols(symbols)).is_err() { - break; - } - } - - let _ = sender.send(SymbolSearchMessage::Done); - Ok(()) - }) - .await??; - Ok(()) -} - -fn collect_rust_symbols( - tree: &tree_sitter::Tree, - source: &[u8], - path: &Path, - root: &Path, - output: &mut Vec, -) { - let relative = diff_paths(path, root).unwrap_or_else(|| path.to_path_buf()); - let display_path = relative.to_string_lossy().into_owned(); - let mut cursor = tree.walk(); - traverse_rust(&mut cursor, source, path, &display_path, output); -} - -fn traverse_rust( - cursor: &mut TreeCursor, - source: &[u8], - path: &Path, - display_path: &str, - output: &mut Vec, -) { - loop { - let node = cursor.node(); - match node.kind() { - "function_item" => { - if let Some(entry) = - build_symbol_entry(SymbolKind::Function, node, source, path, display_path) - { - output.push(entry); - } - } - "struct_item" => { - if let Some(entry) = - build_symbol_entry(SymbolKind::Struct, node, source, path, display_path) - { - output.push(entry); - } - } - "enum_item" => { - if let Some(entry) = - build_symbol_entry(SymbolKind::Enum, node, source, path, display_path) - { - output.push(entry); - } - } - "trait_item" => { - if let Some(entry) = - build_symbol_entry(SymbolKind::Trait, node, source, path, display_path) - { - output.push(entry); - } - } - "mod_item" => { - if let Some(entry) = - build_symbol_entry(SymbolKind::Module, node, source, path, display_path) - { - output.push(entry); - } - } - "type_item" => { - if let Some(entry) = - build_symbol_entry(SymbolKind::TypeAlias, node, source, path, display_path) - { - output.push(entry); - } - } - _ => {} - } - - if cursor.goto_first_child() { - traverse_rust(cursor, source, path, display_path, output); - cursor.goto_parent(); - } - - if !cursor.goto_next_sibling() { - break; - } - } -} - -fn build_symbol_entry( - kind: SymbolKind, - node: Node, - source: &[u8], - path: &Path, - display_path: &str, -) -> Option { - let name_node = node.child_by_field_name("name")?; - let name = name_node.utf8_text(source).ok()?.to_string(); - let line = name_node.start_position().row as u32 + 1; - Some(SymbolEntry { - name, - kind, - file: path.to_path_buf(), - display_path: display_path.to_string(), - line, - }) -} - -async fn run_repo_search( - root: PathBuf, - query: String, - sender: mpsc::UnboundedSender, -) -> Result<()> { - let mut command = Command::new("rg"); - command - .current_dir(&root) - .arg("--json") - .arg("--no-heading") - .arg("--color=never") - .arg("--line-number") - .arg("--column") - .arg("--smart-case") - .arg("--max-columns=300") - .arg(&query) - .arg(".") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - let mut child = command.spawn().context("failed to spawn ripgrep")?; - let stdout = child - .stdout - .take() - .ok_or_else(|| anyhow!("ripgrep produced no stdout"))?; - - let stderr_future = child.stderr.take().map(capture_stderr); - - let mut reader = BufReader::new(stdout).lines(); - let mut matches = 0usize; - - while let Some(line) = reader.next_line().await? { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - match serde_json::from_str::(trimmed) { - Ok(RipGrepEvent::Begin { path }) => { - let absolute = canonicalize_relative(&root, path.text); - let _ = sender.send(RepoSearchMessage::File { path: absolute }); - } - Ok(RipGrepEvent::Match(data)) => { - matches += 1; - let absolute = canonicalize_relative(&root, data.path.text); - let preview = data - .lines - .text - .trim_end_matches(&['\r', '\n'][..]) - .to_string(); - let mut column = 1; - let mut matched = None; - - if let Some(submatch) = data.submatches.first() { - column = compute_column(&preview, submatch.start); - matched = Some(submatch.matcher.text.clone()); - } - - let message = RepoSearchMessage::Match { - path: absolute, - line_number: data.line_number as u32, - column, - preview, - matched, - }; - let _ = sender.send(message); - } - Ok(RipGrepEvent::Summary { .. }) => { - // ignore summary; we derive counts from match events. - } - Ok(RipGrepEvent::End { .. }) => {} - Ok(RipGrepEvent::Other) => {} - Err(err) => { - let _ = sender.send(RepoSearchMessage::Error(format!( - "ripgrep parse error: {err}" - ))); - } - } - } - - let status = child.wait().await?; - let stderr_output = if let Some(fut) = stderr_future { - fut.await - } else { - String::new() - }; - - if status.success() || status.code() == Some(1) { - let _ = sender.send(RepoSearchMessage::Done { matches }); - } else { - let message = if stderr_output.trim().is_empty() { - format!("ripgrep exited with status: {status}") - } else { - stderr_output - }; - let _ = sender.send(RepoSearchMessage::Error(message)); - } - - Ok(()) -} - -fn canonicalize_relative(root: &Path, path_text: String) -> PathBuf { - let candidate = PathBuf::from(path_text); - if candidate.is_absolute() { - candidate - } else { - root.join(candidate) - } -} - -fn compute_column(preview: &str, byte_offset: usize) -> u32 { - let slice = preview - .get(..byte_offset) - .unwrap_or(preview) - .chars() - .count(); - (slice + 1) as u32 -} - -async fn capture_stderr(stderr: ChildStderr) -> String { - let mut reader = BufReader::new(stderr); - let mut buf = String::new(); - if reader.read_to_string(&mut buf).await.is_err() { - return String::new(); - } - buf -} - -#[derive(Deserialize)] -#[serde(tag = "type", content = "data")] -enum RipGrepEvent { - #[serde(rename = "begin")] - Begin { path: RgPath }, - #[serde(rename = "match")] - Match(RgMatch), - #[serde(rename = "summary")] - Summary { - #[serde(rename = "stats")] - _stats: Option, - }, - #[serde(rename = "end")] - End { - #[serde(rename = "path")] - _path: Option, - }, - #[serde(other)] - Other, -} - -#[derive(Deserialize)] -struct RgPath { - text: String, -} - -#[derive(Deserialize)] -struct RgMatch { - path: RgPath, - lines: RgLines, - #[serde(default)] - line_number: u64, - #[serde(default)] - submatches: Vec, -} - -#[derive(Deserialize)] -struct RgLines { - text: String, -} - -#[derive(Deserialize)] -struct RgSubMatch { - #[serde(rename = "match")] - matcher: RgSubMatchText, - start: usize, - #[allow(dead_code)] - end: usize, -} - -#[derive(Deserialize)] -struct RgSubMatchText { - text: String, -} - -#[derive(Deserialize)] -struct RgSummaryStats { - #[allow(dead_code)] - matches: Option, -} diff --git a/crates/owlen-tui/src/state/workspace.rs b/crates/owlen-tui/src/state/workspace.rs deleted file mode 100644 index 0147765..0000000 --- a/crates/owlen-tui/src/state/workspace.rs +++ /dev/null @@ -1,923 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use owlen_core::state::AutoScroll; -use serde::{Deserialize, Serialize}; - -/// Cardinal direction used for navigating between panes or resizing splits. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum PaneDirection { - Left, - Right, - Up, - Down, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ChildSide { - First, - Second, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct PathEntry { - axis: SplitAxis, - side: ChildSide, -} - -/// Identifier assigned to each pane rendered inside a tab. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct PaneId(u64); - -impl PaneId { - fn next(counter: &mut u64) -> Self { - *counter += 1; - PaneId(*counter) - } - - pub fn raw(self) -> u64 { - self.0 - } - - pub fn from_raw(raw: u64) -> Self { - PaneId(raw) - } -} - -/// Identifier used to refer to a tab within the workspace. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct TabId(u64); - -impl TabId { - fn next(counter: &mut u64) -> Self { - *counter += 1; - TabId(*counter) - } - - pub fn raw(self) -> u64 { - self.0 - } - - pub fn from_raw(raw: u64) -> Self { - TabId(raw) - } -} - -/// Direction used when splitting a pane. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum SplitAxis { - /// Split horizontally to create a pane below the current one. - Horizontal, - /// Split vertically to create a pane to the right of the current one. - Vertical, -} - -/// Layout node describing either a leaf pane or a container split. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum LayoutNode { - Leaf(PaneId), - Split { - axis: SplitAxis, - ratio: f32, - first: Box, - second: Box, - }, -} - -impl LayoutNode { - pub fn ensure_ratio_bounds(&mut self) { - match self { - LayoutNode::Split { - ratio, - first, - second, - .. - } => { - *ratio = ratio.clamp(0.1, 0.9); - first.ensure_ratio_bounds(); - second.ensure_ratio_bounds(); - } - LayoutNode::Leaf(_) => {} - } - } - - pub fn nudge_ratio(&mut self, delta: f32) { - match self { - LayoutNode::Split { ratio, .. } => { - *ratio = (*ratio + delta).clamp(0.1, 0.9); - } - LayoutNode::Leaf(_) => {} - } - } - - fn replace_leaf(&mut self, target: PaneId, replacement: LayoutNode) -> bool { - match self { - LayoutNode::Leaf(id) => { - if *id == target { - *self = replacement; - true - } else { - false - } - } - LayoutNode::Split { first, second, .. } => { - first.replace_leaf(target, replacement.clone()) - || second.replace_leaf(target, replacement) - } - } - } - - pub fn iter_leaves<'a>(&'a self, panes: &'a HashMap) -> Vec<&'a CodePane> { - let mut collected = Vec::new(); - self.collect_leaves(panes, &mut collected); - collected - } - - fn collect_leaves<'a>( - &'a self, - panes: &'a HashMap, - output: &mut Vec<&'a CodePane>, - ) { - match self { - LayoutNode::Leaf(id) => { - if let Some(pane) = panes.get(id) { - output.push(pane); - } - } - LayoutNode::Split { first, second, .. } => { - first.collect_leaves(panes, output); - second.collect_leaves(panes, output); - } - } - } - - fn path_to(&self, target: PaneId) -> Option> { - let mut path = Vec::new(); - if self.path_to_inner(target, &mut path) { - Some(path) - } else { - None - } - } - - fn path_to_inner(&self, target: PaneId, path: &mut Vec) -> bool { - match self { - LayoutNode::Leaf(id) => *id == target, - LayoutNode::Split { - axis, - first, - second, - .. - } => { - path.push(PathEntry { - axis: *axis, - side: ChildSide::First, - }); - if first.path_to_inner(target, path) { - return true; - } - path.pop(); - path.push(PathEntry { - axis: *axis, - side: ChildSide::Second, - }); - if second.path_to_inner(target, path) { - return true; - } - path.pop(); - false - } - } - } - - fn subtree(&self, path: &[PathEntry]) -> Option<&LayoutNode> { - let mut node = self; - for entry in path { - match node { - LayoutNode::Split { first, second, .. } => { - node = match entry.side { - ChildSide::First => first.as_ref(), - ChildSide::Second => second.as_ref(), - }; - } - LayoutNode::Leaf(_) => return None, - } - } - Some(node) - } - - fn subtree_mut(&mut self, path: &[PathEntry]) -> Option<&mut LayoutNode> { - let mut node = self; - for entry in path { - match node { - LayoutNode::Split { first, second, .. } => { - node = match entry.side { - ChildSide::First => first.as_mut(), - ChildSide::Second => second.as_mut(), - }; - } - LayoutNode::Leaf(_) => return None, - } - } - Some(node) - } - - fn extreme_leaf(&self, prefer_second: bool) -> Option { - match self { - LayoutNode::Leaf(id) => Some(*id), - LayoutNode::Split { first, second, .. } => { - if prefer_second { - second - .extreme_leaf(prefer_second) - .or_else(|| first.extreme_leaf(prefer_second)) - } else { - first - .extreme_leaf(prefer_second) - .or_else(|| second.extreme_leaf(prefer_second)) - } - } - } - } -} - -/// Renderable pane that holds file contents and scroll state. -#[derive(Debug, Clone)] -pub struct CodePane { - pub id: PaneId, - pub absolute_path: Option, - pub display_path: Option, - pub title: String, - pub lines: Vec, - pub scroll: AutoScroll, - pub viewport_height: usize, - pub is_dirty: bool, - pub is_staged: bool, -} - -impl CodePane { - pub fn new(id: PaneId) -> Self { - Self { - id, - absolute_path: None, - display_path: None, - title: "Untitled".to_string(), - lines: Vec::new(), - scroll: AutoScroll::default(), - viewport_height: 0, - is_dirty: false, - is_staged: false, - } - } - - pub fn set_contents( - &mut self, - absolute_path: Option, - display_path: Option, - lines: Vec, - ) { - self.absolute_path = absolute_path; - self.display_path = display_path; - self.title = self - .absolute_path - .as_ref() - .and_then(|path| path.file_name().map(|s| s.to_string_lossy().into_owned())) - .or_else(|| self.display_path.clone()) - .unwrap_or_else(|| "Untitled".to_string()); - self.lines = lines; - self.scroll = AutoScroll::default(); - self.scroll.content_len = self.lines.len(); - self.scroll.stick_to_bottom = false; - self.scroll.scroll = 0; - } - - pub fn update_paths(&mut self, absolute_path: Option, display_path: Option) { - self.absolute_path = absolute_path; - self.display_path = display_path.clone(); - self.title = self - .absolute_path - .as_ref() - .and_then(|path| path.file_name().map(|s| s.to_string_lossy().into_owned())) - .or(display_path) - .unwrap_or_else(|| "Untitled".to_string()); - } - - pub fn clear(&mut self) { - self.absolute_path = None; - self.display_path = None; - self.title = "Untitled".to_string(); - self.lines.clear(); - self.scroll = AutoScroll::default(); - self.viewport_height = 0; - self.is_dirty = false; - self.is_staged = false; - } - - pub fn set_viewport_height(&mut self, height: usize) { - self.viewport_height = height; - } - - pub fn display_path(&self) -> Option<&str> { - self.display_path.as_deref() - } - - pub fn absolute_path(&self) -> Option<&Path> { - self.absolute_path.as_deref() - } -} - -/// Individual tab containing a layout tree and panes. -#[derive(Debug, Clone)] -pub struct EditorTab { - pub id: TabId, - pub title: String, - pub root: LayoutNode, - pub panes: HashMap, - pub active: PaneId, -} - -impl EditorTab { - fn new(id: TabId, title: String, pane: CodePane) -> Self { - let active = pane.id; - let mut panes = HashMap::new(); - panes.insert(pane.id, pane); - Self { - id, - title, - root: LayoutNode::Leaf(active), - panes, - active, - } - } - - pub fn active_pane(&self) -> Option<&CodePane> { - self.panes.get(&self.active) - } - - pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> { - self.panes.get_mut(&self.active) - } - - pub fn set_active(&mut self, pane: PaneId) { - if self.panes.contains_key(&pane) { - self.active = pane; - } - } - - pub fn update_title_from_active(&mut self) { - if let Some(pane) = self.active_pane() { - self.title = pane - .absolute_path - .as_ref() - .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned())) - .or_else(|| pane.display_path.clone()) - .unwrap_or_else(|| "Untitled".to_string()); - } - } - - fn active_path(&self) -> Option> { - self.root.path_to(self.active) - } - - pub fn move_focus(&mut self, direction: PaneDirection) -> bool { - let path = match self.active_path() { - Some(path) => path, - None => return false, - }; - let axis = match direction { - PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical, - PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal, - }; - - for (idx, entry) in path.iter().enumerate().rev() { - if entry.axis != axis { - continue; - } - - let (required_side, target_side, prefer_second) = match direction { - PaneDirection::Left => (ChildSide::Second, ChildSide::First, true), - PaneDirection::Right => (ChildSide::First, ChildSide::Second, false), - PaneDirection::Up => (ChildSide::Second, ChildSide::First, true), - PaneDirection::Down => (ChildSide::First, ChildSide::Second, false), - }; - - if entry.side != required_side { - continue; - } - - let parent_path = &path[..idx]; - let Some(parent) = self.root.subtree(parent_path) else { - continue; - }; - - if let LayoutNode::Split { first, second, .. } = parent { - let target = match target_side { - ChildSide::First => first.as_ref(), - ChildSide::Second => second.as_ref(), - }; - if let Some(pane_id) = target.extreme_leaf(prefer_second) - && self.panes.contains_key(&pane_id) - { - self.active = pane_id; - self.update_title_from_active(); - return true; - } - } - } - - false - } - - pub fn resize_active_step(&mut self, direction: PaneDirection, amount: f32) -> Option { - let path = self.active_path()?; - - let axis = match direction { - PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical, - PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal, - }; - - let (idx, entry) = path - .iter() - .enumerate() - .rev() - .find(|(_, entry)| entry.axis == axis)?; - - let parent_path = &path[..idx]; - let parent = self.root.subtree_mut(parent_path)?; - - let LayoutNode::Split { ratio, .. } = parent else { - return None; - }; - - let sign = match direction { - PaneDirection::Left => { - if entry.side == ChildSide::First { - 1.0 - } else { - -1.0 - } - } - PaneDirection::Right => { - if entry.side == ChildSide::First { - -1.0 - } else { - 1.0 - } - } - PaneDirection::Up => { - if entry.side == ChildSide::First { - 1.0 - } else { - -1.0 - } - } - PaneDirection::Down => { - if entry.side == ChildSide::First { - -1.0 - } else { - 1.0 - } - } - }; - - let mut new_ratio = (*ratio + amount * sign).clamp(0.1, 0.9); - if (new_ratio - *ratio).abs() < f32::EPSILON { - return Some(self.active_share_from(entry.side, new_ratio)); - } - *ratio = new_ratio; - new_ratio = new_ratio.clamp(0.1, 0.9); - Some(self.active_share_from(entry.side, new_ratio)) - } - - pub fn snap_active_share( - &mut self, - direction: PaneDirection, - desired_share: f32, - ) -> Option { - let path = self.active_path()?; - - let axis = match direction { - PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical, - PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal, - }; - - let (idx, entry) = path - .iter() - .enumerate() - .rev() - .find(|(_, entry)| entry.axis == axis)?; - - let parent_path = &path[..idx]; - let parent = self.root.subtree_mut(parent_path)?; - - let LayoutNode::Split { ratio, .. } = parent else { - return None; - }; - - let mut target_ratio = match entry.side { - ChildSide::First => desired_share, - ChildSide::Second => 1.0 - desired_share, - } - .clamp(0.1, 0.9); - - if (target_ratio - *ratio).abs() < f32::EPSILON { - return Some(self.active_share_from(entry.side, target_ratio)); - } - - *ratio = target_ratio; - target_ratio = target_ratio.clamp(0.1, 0.9); - Some(self.active_share_from(entry.side, target_ratio)) - } - - pub fn active_share(&self) -> Option { - let path = self.active_path()?; - let (idx, entry) = - path.iter().enumerate().rev().find(|(_, entry)| { - matches!(entry.axis, SplitAxis::Horizontal | SplitAxis::Vertical) - })?; - let parent_path = &path[..idx]; - let parent = self.root.subtree(parent_path)?; - if let LayoutNode::Split { ratio, .. } = parent { - Some(self.active_share_from(entry.side, *ratio)) - } else { - None - } - } - - fn active_share_from(&self, side: ChildSide, ratio: f32) -> f32 { - match side { - ChildSide::First => ratio, - ChildSide::Second => 1.0 - ratio, - } - } -} - -/// Top-level workspace managing tabs and panes for the code viewer. -#[derive(Debug, Clone)] -pub struct CodeWorkspace { - tabs: Vec, - active_tab: usize, - next_tab_id: u64, - next_pane_id: u64, -} - -const WORKSPACE_SNAPSHOT_VERSION: u32 = 1; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceSnapshot { - version: u32, - active_tab: usize, - next_tab_id: u64, - next_pane_id: u64, - tabs: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct TabSnapshot { - id: u64, - title: String, - active: u64, - root: LayoutNode, - panes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PaneSnapshot { - id: u64, - absolute_path: Option, - display_path: Option, - is_dirty: bool, - is_staged: bool, - scroll: ScrollSnapshot, - viewport_height: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScrollSnapshot { - pub scroll: usize, - pub stick_to_bottom: bool, -} - -#[derive(Debug, Clone)] -pub struct PaneRestoreRequest { - pub pane_id: PaneId, - pub absolute_path: Option, - pub display_path: Option, - pub scroll: ScrollSnapshot, -} - -impl Default for CodeWorkspace { - fn default() -> Self { - Self::new() - } -} - -impl CodeWorkspace { - pub fn new() -> Self { - let mut next_tab_id = 0; - let mut next_pane_id = 0; - let pane_id = PaneId::next(&mut next_pane_id); - let first_pane = CodePane::new(pane_id); - let tab_id = TabId::next(&mut next_tab_id); - let title = format!("Tab {}", tab_id.0); - let first_tab = EditorTab::new(tab_id, title, first_pane); - Self { - tabs: vec![first_tab], - active_tab: 0, - next_tab_id, - next_pane_id, - } - } - - pub fn tabs(&self) -> &[EditorTab] { - &self.tabs - } - - pub fn tabs_mut(&mut self) -> &mut [EditorTab] { - &mut self.tabs - } - - pub fn active_tab_index(&self) -> usize { - self.active_tab.min(self.tabs.len().saturating_sub(1)) - } - - pub fn active_tab(&self) -> Option<&EditorTab> { - self.tabs.get(self.active_tab_index()) - } - - pub fn active_tab_mut(&mut self) -> Option<&mut EditorTab> { - let idx = self.active_tab_index(); - self.tabs.get_mut(idx) - } - - pub fn active_pane(&self) -> Option<&CodePane> { - self.active_tab().and_then(|tab| tab.active_pane()) - } - - pub fn panes(&self) -> impl Iterator + '_ { - self.tabs.iter().flat_map(|tab| tab.panes.values()) - } - - pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> { - self.active_tab_mut().and_then(|tab| tab.active_pane_mut()) - } - - pub fn set_active_tab(&mut self, index: usize) { - if index < self.tabs.len() { - self.active_tab = index; - } - } - - pub fn ensure_tab(&mut self) { - if self.tabs.is_empty() { - let mut next_tab_id = self.next_tab_id; - let mut next_pane_id = self.next_pane_id; - let pane_id = PaneId::next(&mut next_pane_id); - let pane = CodePane::new(pane_id); - let tab_id = TabId::next(&mut next_tab_id); - let title = format!("Tab {}", tab_id.0); - let tab = EditorTab::new(tab_id, title, pane); - self.tabs.push(tab); - self.active_tab = 0; - self.next_tab_id = next_tab_id; - self.next_pane_id = next_pane_id; - } - } - - pub fn set_active_contents( - &mut self, - absolute: Option, - display: Option, - lines: Vec, - ) { - self.ensure_tab(); - if let Some(tab) = self.active_tab_mut() { - if let Some(pane) = tab.active_pane_mut() { - pane.set_contents(absolute, display, lines); - } - tab.update_title_from_active(); - } - } - - pub fn clear_active_pane(&mut self) { - if let Some(tab) = self.active_tab_mut() { - if let Some(pane) = tab.active_pane_mut() { - pane.clear(); - } - tab.update_title_from_active(); - } - } - - pub fn set_active_viewport_height(&mut self, height: usize) { - if let Some(pane) = self.active_pane_mut() { - pane.set_viewport_height(height); - } - } - - pub fn active_pane_id(&self) -> Option { - self.active_tab().map(|tab| tab.active) - } - - pub fn split_active(&mut self, axis: SplitAxis) -> Option { - self.ensure_tab(); - let active_id = self.active_tab()?.active; - let new_pane_id = PaneId::next(&mut self.next_pane_id); - let replacement = LayoutNode::Split { - axis, - ratio: 0.5, - first: Box::new(LayoutNode::Leaf(active_id)), - second: Box::new(LayoutNode::Leaf(new_pane_id)), - }; - - self.active_tab_mut().and_then(|tab| { - if tab.root.replace_leaf(active_id, replacement) { - tab.panes.insert(new_pane_id, CodePane::new(new_pane_id)); - tab.active = new_pane_id; - Some(new_pane_id) - } else { - None - } - }) - } - - pub fn open_new_tab(&mut self) -> PaneId { - let pane_id = PaneId::next(&mut self.next_pane_id); - let pane = CodePane::new(pane_id); - let tab_id = TabId::next(&mut self.next_tab_id); - let title = format!("Tab {}", tab_id.0); - let tab = EditorTab::new(tab_id, title, pane); - self.tabs.push(tab); - self.active_tab = self.tabs.len().saturating_sub(1); - pane_id - } - - pub fn snapshot(&self) -> WorkspaceSnapshot { - let tabs = self - .tabs - .iter() - .map(|tab| { - let panes = tab - .panes - .values() - .map(|pane| PaneSnapshot { - id: pane.id.raw(), - absolute_path: pane - .absolute_path - .as_ref() - .map(|p| p.to_string_lossy().into_owned()), - display_path: pane.display_path.clone(), - is_dirty: pane.is_dirty, - is_staged: pane.is_staged, - scroll: ScrollSnapshot { - scroll: pane.scroll.scroll, - stick_to_bottom: pane.scroll.stick_to_bottom, - }, - viewport_height: pane.viewport_height, - }) - .collect(); - - TabSnapshot { - id: tab.id.raw(), - title: tab.title.clone(), - active: tab.active.raw(), - root: tab.root.clone(), - panes, - } - }) - .collect(); - - WorkspaceSnapshot { - version: WORKSPACE_SNAPSHOT_VERSION, - active_tab: self.active_tab_index(), - next_tab_id: self.next_tab_id, - next_pane_id: self.next_pane_id, - tabs, - } - } - - pub fn apply_snapshot(&mut self, snapshot: WorkspaceSnapshot) -> Vec { - if snapshot.version != WORKSPACE_SNAPSHOT_VERSION { - return Vec::new(); - } - - let mut restore_requests = Vec::new(); - let mut tabs = Vec::new(); - - for tab_snapshot in snapshot.tabs { - let mut panes = HashMap::new(); - for pane_snapshot in tab_snapshot.panes { - let pane_id = PaneId::from_raw(pane_snapshot.id); - let mut pane = CodePane::new(pane_id); - pane.absolute_path = pane_snapshot.absolute_path.as_ref().map(PathBuf::from); - pane.display_path = pane_snapshot.display_path.clone(); - pane.is_dirty = pane_snapshot.is_dirty; - pane.is_staged = pane_snapshot.is_staged; - pane.scroll.scroll = pane_snapshot.scroll.scroll; - pane.scroll.stick_to_bottom = pane_snapshot.scroll.stick_to_bottom; - pane.viewport_height = pane_snapshot.viewport_height; - pane.scroll.content_len = pane.lines.len(); - pane.title = pane - .absolute_path - .as_ref() - .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned())) - .or_else(|| pane.display_path.clone()) - .unwrap_or_else(|| "Untitled".to_string()); - panes.insert(pane_id, pane); - - if pane_snapshot.absolute_path.is_some() { - restore_requests.push(PaneRestoreRequest { - pane_id, - absolute_path: pane_snapshot.absolute_path.map(PathBuf::from), - display_path: pane_snapshot.display_path.clone(), - scroll: pane_snapshot.scroll.clone(), - }); - } - } - - if panes.is_empty() { - continue; - } - - let tab_id = TabId::from_raw(tab_snapshot.id); - let mut tab = EditorTab { - id: tab_id, - title: tab_snapshot.title, - root: tab_snapshot.root, - panes, - active: PaneId::from_raw(tab_snapshot.active), - }; - tab.update_title_from_active(); - tabs.push(tab); - } - - if tabs.is_empty() { - return Vec::new(); - } - - self.tabs = tabs; - self.active_tab = snapshot.active_tab.min(self.tabs.len().saturating_sub(1)); - self.next_tab_id = snapshot.next_tab_id; - self.next_pane_id = snapshot.next_pane_id; - - restore_requests - } - - pub fn move_focus(&mut self, direction: PaneDirection) -> bool { - let active_index = self.active_tab_index(); - if let Some(tab) = self.tabs.get_mut(active_index) { - tab.move_focus(direction) - } else { - false - } - } - - pub fn resize_active_step(&mut self, direction: PaneDirection, amount: f32) -> Option { - let active_index = self.active_tab_index(); - self.tabs - .get_mut(active_index) - .and_then(|tab| tab.resize_active_step(direction, amount)) - } - - pub fn snap_active_share( - &mut self, - direction: PaneDirection, - desired_share: f32, - ) -> Option { - let active_index = self.active_tab_index(); - self.tabs - .get_mut(active_index) - .and_then(|tab| tab.snap_active_share(direction, desired_share)) - } - - pub fn active_share(&self) -> Option { - self.active_tab().and_then(|tab| tab.active_share()) - } - - pub fn set_pane_contents( - &mut self, - pane_id: PaneId, - absolute: Option, - display: Option, - lines: Vec, - ) -> bool { - for tab in &mut self.tabs { - if let Some(pane) = tab.panes.get_mut(&pane_id) { - pane.set_contents(absolute, display, lines); - tab.update_title_from_active(); - return true; - } - } - false - } - - pub fn restore_scroll(&mut self, pane_id: PaneId, snapshot: &ScrollSnapshot) -> bool { - for tab in &mut self.tabs { - if let Some(pane) = tab.panes.get_mut(&pane_id) { - pane.scroll.scroll = snapshot.scroll; - pane.scroll.stick_to_bottom = snapshot.stick_to_bottom; - pane.scroll.content_len = pane.lines.len(); - return true; - } - } - false - } -} diff --git a/crates/owlen-tui/src/theme_helpers.rs b/crates/owlen-tui/src/theme_helpers.rs deleted file mode 100644 index a4f11b6..0000000 --- a/crates/owlen-tui/src/theme_helpers.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Helper functions for working with themes in the TUI -//! -//! This module provides convenient wrappers and conversions for working with -//! owlen-core themes in ratatui contexts. - -use crate::color_convert::to_ratatui_color; -use owlen_core::Theme; -use ratatui::style::Color as RatatuiColor; - -/// A wrapper around Theme that provides convenient access to ratatui colors -pub struct RatatuiTheme<'a> { - theme: &'a Theme, -} - -impl<'a> RatatuiTheme<'a> { - pub fn new(theme: &'a Theme) -> Self { - Self { theme } - } - - // Provide accessor methods that return ratatui colors - pub fn text(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.text) - } - - pub fn background(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.background) - } - - pub fn focused_panel_border(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.focused_panel_border) - } - - pub fn unfocused_panel_border(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.unfocused_panel_border) - } - - pub fn focus_beacon_fg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.focus_beacon_fg) - } - - pub fn focus_beacon_bg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.focus_beacon_bg) - } - - pub fn unfocused_beacon_fg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.unfocused_beacon_fg) - } - - pub fn pane_header_active(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.pane_header_active) - } - - pub fn pane_header_inactive(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.pane_header_inactive) - } - - pub fn pane_hint_text(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.pane_hint_text) - } - - pub fn user_message_role(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.user_message_role) - } - - pub fn assistant_message_role(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.assistant_message_role) - } - - pub fn tool_output(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.tool_output) - } - - pub fn thinking_panel_title(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.thinking_panel_title) - } - - pub fn command_bar_background(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.command_bar_background) - } - - pub fn status_background(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.status_background) - } - - pub fn mode_normal(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.mode_normal) - } - - pub fn mode_editing(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.mode_editing) - } - - pub fn mode_model_selection(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.mode_model_selection) - } - - pub fn mode_provider_selection(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.mode_provider_selection) - } - - pub fn mode_help(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.mode_help) - } - - pub fn mode_visual(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.mode_visual) - } - - pub fn mode_command(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.mode_command) - } - - pub fn selection_bg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.selection_bg) - } - - pub fn selection_fg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.selection_fg) - } - - pub fn cursor(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.cursor) - } - - pub fn code_block_background(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.code_block_background) - } - - pub fn code_block_border(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.code_block_border) - } - - pub fn code_block_text(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.code_block_text) - } - - pub fn code_block_keyword(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.code_block_keyword) - } - - pub fn code_block_string(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.code_block_string) - } - - pub fn code_block_comment(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.code_block_comment) - } - - pub fn placeholder(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.placeholder) - } - - pub fn error(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.error) - } - - pub fn info(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.info) - } - - pub fn agent_thought(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_thought) - } - - pub fn agent_action(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_action) - } - - pub fn agent_action_input(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_action_input) - } - - pub fn agent_observation(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_observation) - } - - pub fn agent_final_answer(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_final_answer) - } - - pub fn agent_badge_running_fg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_badge_running_fg) - } - - pub fn agent_badge_running_bg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_badge_running_bg) - } - - pub fn agent_badge_idle_fg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_badge_idle_fg) - } - - pub fn agent_badge_idle_bg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.agent_badge_idle_bg) - } - - pub fn operating_chat_fg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.operating_chat_fg) - } - - pub fn operating_chat_bg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.operating_chat_bg) - } - - pub fn operating_code_fg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.operating_code_fg) - } - - pub fn operating_code_bg(&self) -> RatatuiColor { - to_ratatui_color(&self.theme.operating_code_bg) - } -} diff --git a/crates/owlen-tui/src/theme_util.rs b/crates/owlen-tui/src/theme_util.rs deleted file mode 100644 index 41250ec..0000000 --- a/crates/owlen-tui/src/theme_util.rs +++ /dev/null @@ -1,96 +0,0 @@ -macro_rules! adjust_fields { - ($theme:expr, $func:expr, $($field:ident),+ $(,)?) => { - $( - $theme.$field = $func($theme.$field); - )+ - }; -} - -use owlen_core::Theme; -use ratatui::style::Color; - -/// Return a clone of `base` with contrast adjustments applied. -/// Positive `steps` increase contrast, negative values decrease it. -pub fn with_contrast(base: &Theme, steps: i8) -> Theme { - if steps == 0 { - return base.clone(); - } - - let factor = (1.0 + (steps as f32) * 0.18).clamp(0.3, 2.0); - let adjust = |color: Color| adjust_color(color, factor); - - let mut theme = base.clone(); - adjust_fields!( - theme, - adjust, - text, - background, - focused_panel_border, - unfocused_panel_border, - focus_beacon_fg, - focus_beacon_bg, - unfocused_beacon_fg, - pane_header_active, - pane_header_inactive, - pane_hint_text, - user_message_role, - assistant_message_role, - tool_output, - thinking_panel_title, - command_bar_background, - status_background, - mode_normal, - mode_editing, - mode_model_selection, - mode_provider_selection, - mode_help, - mode_visual, - mode_command, - selection_bg, - selection_fg, - cursor, - code_block_background, - code_block_border, - code_block_text, - code_block_keyword, - code_block_string, - code_block_comment, - placeholder, - error, - info, - agent_thought, - agent_action, - agent_action_input, - agent_observation, - agent_final_answer, - agent_badge_running_fg, - agent_badge_running_bg, - agent_badge_idle_fg, - agent_badge_idle_bg, - operating_chat_fg, - operating_chat_bg, - operating_code_fg, - operating_code_bg - ); - - theme -} - -fn adjust_color(color: Color, factor: f32) -> Color { - match color { - Color::Rgb(r, g, b) => { - let adjust_component = |component: u8| -> u8 { - let normalized = component as f32 / 255.0; - let contrasted = ((normalized - 0.5) * factor + 0.5).clamp(0.0, 1.0); - (contrasted * 255.0).round().clamp(0.0, 255.0) as u8 - }; - - Color::Rgb( - adjust_component(r), - adjust_component(g), - adjust_component(b), - ) - } - _ => color, - } -} diff --git a/crates/owlen-tui/src/toast.rs b/crates/owlen-tui/src/toast.rs deleted file mode 100644 index 44f4fea..0000000 --- a/crates/owlen-tui/src/toast.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::collections::VecDeque; -use std::time::{Duration, Instant}; - -/// Severity level for toast notifications. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToastLevel { - Info, - Success, - Warning, - Error, -} - -#[derive(Debug, Clone)] -pub struct Toast { - pub message: String, - pub level: ToastLevel, - created: Instant, - duration: Duration, - shortcut_hint: Option, -} - -impl Toast { - fn new( - message: String, - level: ToastLevel, - lifetime: Duration, - shortcut_hint: Option, - ) -> Self { - Self { - message, - level, - created: Instant::now(), - duration: lifetime, - shortcut_hint, - } - } - - fn is_expired(&self, now: Instant) -> bool { - now.duration_since(self.created) >= self.duration - } - - pub fn shortcut(&self) -> Option<&str> { - self.shortcut_hint.as_deref() - } - - pub fn elapsed_fraction(&self, now: Instant) -> f32 { - let elapsed = now.saturating_duration_since(self.created).as_secs_f32(); - let total = self.duration.as_secs_f32().max(f32::MIN_POSITIVE); - (elapsed / total).clamp(0.0, 1.0) - } - - pub fn remaining_fraction(&self, now: Instant) -> f32 { - 1.0 - self.elapsed_fraction(now) - } - - pub fn remaining_duration(&self, now: Instant) -> Duration { - let elapsed = now.saturating_duration_since(self.created); - if elapsed >= self.duration { - Duration::from_secs(0) - } else { - self.duration - elapsed - } - } -} - -#[derive(Debug, Clone)] -pub struct ToastHistoryEntry { - pub message: String, - pub level: ToastLevel, - recorded: Instant, - pub shortcut_hint: Option, -} - -impl ToastHistoryEntry { - fn new(message: String, level: ToastLevel, shortcut_hint: Option) -> Self { - Self { - message, - level, - recorded: Instant::now(), - shortcut_hint, - } - } - - pub fn recorded(&self) -> Instant { - self.recorded - } -} - -/// Fixed-size toast queue with automatic expiration. -#[derive(Debug)] -pub struct ToastManager { - items: VecDeque, - max_active: usize, - lifetime: Duration, - history: VecDeque, - history_limit: usize, -} - -impl Default for ToastManager { - fn default() -> Self { - Self::new() - } -} - -impl ToastManager { - pub fn new() -> Self { - Self { - items: VecDeque::new(), - max_active: 3, - lifetime: Duration::from_secs(3), - history: VecDeque::new(), - history_limit: 20, - } - } - - pub fn with_lifetime(mut self, duration: Duration) -> Self { - self.lifetime = duration; - self - } - - pub fn push(&mut self, message: impl Into, level: ToastLevel) { - self.push_internal(message.into(), level, None); - } - - pub fn push_with_hint( - &mut self, - message: impl Into, - level: ToastLevel, - shortcut_hint: impl Into, - ) { - self.push_internal( - message.into(), - level, - Some(shortcut_hint.into().trim().to_string()), - ); - } - - fn push_internal(&mut self, message: String, level: ToastLevel, shortcut_hint: Option) { - let toast = Toast::new(message.clone(), level, self.lifetime, shortcut_hint.clone()); - self.items.push_front(toast); - while self.items.len() > self.max_active { - self.items.pop_back(); - } - self.history - .push_front(ToastHistoryEntry::new(message, level, shortcut_hint)); - if self.history.len() > self.history_limit { - self.history.pop_back(); - } - } - - pub fn retain_active(&mut self) { - let now = Instant::now(); - self.items.retain(|toast| !toast.is_expired(now)); - } - - pub fn iter(&self) -> impl Iterator { - self.items.iter() - } - - pub fn history(&self) -> impl Iterator { - self.history.iter() - } - - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::thread::sleep; - - #[test] - fn manager_limits_active_toasts() { - let mut manager = ToastManager::new(); - manager.push("first", ToastLevel::Info); - manager.push("second", ToastLevel::Warning); - manager.push("third", ToastLevel::Success); - manager.push("fourth", ToastLevel::Error); - - let collected: Vec<_> = manager.iter().map(|toast| toast.message.clone()).collect(); - assert_eq!(collected.len(), 3); - assert_eq!(collected[0], "fourth"); - assert_eq!(collected[2], "second"); - } - - #[test] - fn manager_expires_toasts_after_lifetime() { - let mut manager = ToastManager::new().with_lifetime(Duration::from_millis(1)); - manager.push("short lived", ToastLevel::Info); - assert!(!manager.is_empty()); - sleep(Duration::from_millis(5)); - manager.retain_active(); - assert!(manager.is_empty()); - } - - #[test] - fn manager_tracks_history_and_hints() { - let mut manager = ToastManager::new(); - manager.push_with_hint("saving project", ToastLevel::Info, "Ctrl+S"); - manager.push("all good", ToastLevel::Success); - - let latest = manager.history().next().expect("history entry"); - assert_eq!(latest.level, ToastLevel::Success); - assert!(latest.shortcut_hint.is_none()); - assert!(manager.history().nth(1).unwrap().shortcut_hint.is_some()); - } -} diff --git a/crates/owlen-tui/src/tui_controller.rs b/crates/owlen-tui/src/tui_controller.rs deleted file mode 100644 index 21ce3ec..0000000 --- a/crates/owlen-tui/src/tui_controller.rs +++ /dev/null @@ -1,44 +0,0 @@ -use async_trait::async_trait; -use owlen_core::ui::UiController; -use tokio::sync::{mpsc, oneshot}; - -/// A request sent from the UiController to the TUI event loop. -#[derive(Debug)] -pub enum TuiRequest { - Confirm { - prompt: String, - tx: oneshot::Sender, - }, -} - -/// An implementation of the UiController trait for the TUI. -/// It uses channels to communicate with the main ChatApp event loop. -pub struct TuiController { - tx: mpsc::UnboundedSender, -} - -impl TuiController { - pub fn new(tx: mpsc::UnboundedSender) -> Self { - Self { tx } - } -} - -#[async_trait] -impl UiController for TuiController { - async fn confirm(&self, prompt: &str) -> bool { - let (tx, rx) = oneshot::channel(); - let request = TuiRequest::Confirm { - prompt: prompt.to_string(), - tx, - }; - - if self.tx.send(request).is_err() { - // Receiver was dropped, so we can't get confirmation. - // Default to false for safety. - return false; - } - - // Wait for the response from the TUI. - rx.await.unwrap_or(false) - } -} diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs deleted file mode 100644 index 5200bce..0000000 --- a/crates/owlen-tui/src/ui.rs +++ /dev/null @@ -1,7219 +0,0 @@ -use log::Level; -use pathdiff::diff_paths; -use ratatui::Frame; -use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::block::Padding; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; -use serde_json; -use std::collections::{HashMap, HashSet}; -use std::path::{Component, Path, PathBuf}; -use std::time::{Duration, Instant}; -use tui_textarea::TextArea; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -use crate::chat_app::{ - AdaptiveLayout, AttachmentPreviewSource, ChatApp, ContextUsage, GaugeKey, GuidanceOverlay, - LayoutSnapshot, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, PanePulse, -}; -use crate::color_convert::to_ratatui_color; -use crate::glass::{GlassPalette, blend_color, gradient_color}; -use crate::highlight; -use crate::state::{ - CodePane, EditorTab, FileFilterMode, FileNode, KeymapBindingDescription, KeymapProfile, - LayoutNode, PaletteGroup, PaneId, RepoSearchRowKind, SplitAxis, VisibleFileEntry, -}; -use crate::toast::{Toast, ToastLevel}; -use crate::widgets::model_picker::render_model_picker; -use owlen_core::types::Role; -use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay}; -use owlen_core::usage::{UsageBand, UsageSnapshot, UsageWindow}; -use owlen_core::{Theme, config::LayerSettings}; -use textwrap::wrap; - -const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -const INLINE_ATTACHMENT_PREVIEW_LINES: usize = 6; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProgressBand { - Normal, - Warning, - Critical, -} - -impl ProgressBand { - fn color(self, theme: &Theme) -> Color { - match self { - ProgressBand::Normal => to_ratatui_color(&theme.info), - ProgressBand::Warning => Color::Yellow, - ProgressBand::Critical => to_ratatui_color(&theme.error), - } - } -} - -#[derive(Debug, Clone, PartialEq)] -struct GaugeDescriptor { - title: String, - detail: String, - percent_label: String, - ratio: f64, - band: ProgressBand, -} - -fn progress_band_color(band: ProgressBand, theme: &Theme) -> Color { - band.color(theme) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StatusLevel { - Info, - Success, - Warning, - Error, -} - -fn status_level_colors(level: StatusLevel, theme: &Theme) -> (Style, Style) { - match level { - StatusLevel::Info => ( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.info)) - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .add_modifier(Modifier::BOLD), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - ), - StatusLevel::Success => ( - Style::default() - .bg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_idle_bg, - )) - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .add_modifier(Modifier::BOLD), - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_idle_bg, - )), - ), - StatusLevel::Warning => ( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.agent_action)) - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .add_modifier(Modifier::BOLD), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.agent_action)), - ), - StatusLevel::Error => ( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.error)) - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .add_modifier(Modifier::BOLD), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.error)), - ), - } -} - -fn status_level_icon(level: StatusLevel) -> &'static str { - match level { - StatusLevel::Info => "ⓘ", - StatusLevel::Success => "✔", - StatusLevel::Warning => "⚠", - StatusLevel::Error => "✖", - } -} - -fn context_progress_band(ratio: f64) -> ProgressBand { - if ratio >= 0.85 { - ProgressBand::Critical - } else if ratio >= 0.60 { - ProgressBand::Warning - } else { - ProgressBand::Normal - } -} - -fn usage_progress_band(band: UsageBand) -> ProgressBand { - match band { - UsageBand::Normal => ProgressBand::Normal, - UsageBand::Warning => ProgressBand::Warning, - UsageBand::Critical => ProgressBand::Critical, - } -} - -fn context_usage_descriptor(usage: ContextUsage) -> Option { - if usage.context_window == 0 { - return None; - } - - let window = usage.context_window as f64; - let ratio = (usage.prompt_tokens as f64 / window).clamp(0.0, 1.0); - let percent = (ratio * 100.0).round(); - let used = format_token_short(usage.prompt_tokens as u64); - let capacity = format_token_short(usage.context_window as u64); - Some(GaugeDescriptor { - title: "Context".to_string(), - detail: format!("{used} / {capacity}"), - percent_label: format!("{percent:.0}%"), - ratio, - band: context_progress_band(ratio), - }) -} - -fn usage_gauge_descriptor( - snapshot: &UsageSnapshot, - window: UsageWindow, -) -> Option { - let metrics = snapshot.window(window); - if metrics.total_tokens == 0 && metrics.quota_tokens.is_none() { - return None; - } - - let (title, shorthand) = match window { - UsageWindow::Hour => ("Cloud hour", "hr"), - UsageWindow::Week => ("Cloud week", "wk"), - }; - - let detail; - let percent_label; - let ratio; - - if let Some(quota) = metrics.quota_tokens { - if quota == 0 { - ratio = 0.0; - detail = "No quota".to_string(); - percent_label = "0%".to_string(); - } else { - ratio = (metrics.total_tokens as f64 / quota as f64).clamp(0.0, 1.0); - let used = format_token_short(metrics.total_tokens); - let quota_text = format_token_short(quota); - let percent = (ratio * 100.0).round(); - detail = format!("{used} / {quota_text}"); - percent_label = format!("{percent:.0}%"); - } - } else { - ratio = 0.0; - detail = format!("{} tokens", format_token_short(metrics.total_tokens)); - percent_label = "–".to_string(); - } - - Some(GaugeDescriptor { - title: title.to_string(), - detail: format!("{detail} · {shorthand}"), - percent_label, - ratio, - band: usage_progress_band(metrics.band()), - }) -} - -fn focus_beacon_span(is_active: bool, is_focused: bool, theme: &Theme) -> Span<'static> { - if !is_active { - return Span::styled( - " ", - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_beacon_fg, - )), - ); - } - - if is_focused { - Span::styled( - "▌", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.focus_beacon_fg, - )) - .bg(crate::color_convert::to_ratatui_color( - &theme.focus_beacon_bg, - )), - ) - } else { - Span::styled( - "▌", - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_beacon_fg, - )), - ) - } -} - -fn panel_title_spans( - label: impl Into, - is_active: bool, - is_focused: bool, - theme: &Theme, -) -> Vec> { - let mut spans: Vec> = Vec::new(); - spans.push(focus_beacon_span(is_active, is_focused, theme)); - spans.push(Span::raw(" ")); - - let mut label_style = Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_inactive, - )); - if is_active { - label_style = Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - )) - .add_modifier(Modifier::BOLD); - if !is_focused { - label_style = label_style.add_modifier(Modifier::DIM); - } - } else { - label_style = label_style.add_modifier(Modifier::DIM); - } - - spans.push(Span::styled(label.into(), label_style)); - spans -} - -fn panel_hint_style(is_focused: bool, theme: &Theme) -> Style { - let mut style = Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text, - )); - if !is_focused { - style = style.add_modifier(Modifier::DIM); - } - style -} - -fn panel_border_style(is_active: bool, is_focused: bool, theme: &Theme) -> Style { - if is_active && is_focused { - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - )) - } else if is_active { - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - )) - } else { - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - )) - .add_modifier(Modifier::DIM) - } -} - -#[cfg(test)] -mod focus_tests { - use super::*; - use ratatui::style::{Modifier, Style}; - use std::path::Path; - - fn theme() -> Theme { - Theme::default() - } - - #[test] - fn beacon_blank_when_inactive() { - let theme = theme(); - let span = focus_beacon_span(false, false, &theme); - assert_eq!(span.content.as_ref(), " "); - assert_eq!( - span.style.fg, - Some(crate::color_convert::to_ratatui_color( - &theme.unfocused_beacon_fg - )) - ); - assert_eq!(span.style.bg, None); - } - - #[test] - fn beacon_highlighted_when_active_and_focused() { - let theme = theme(); - let span = focus_beacon_span(true, true, &theme); - assert_eq!(span.content.as_ref(), "▌"); - assert_eq!( - span.style.fg, - Some(crate::color_convert::to_ratatui_color( - &theme.focus_beacon_fg - )) - ); - assert_eq!( - span.style.bg, - Some(crate::color_convert::to_ratatui_color( - &theme.focus_beacon_bg - )) - ); - } - - #[test] - fn panel_title_spans_apply_active_styles() { - let theme = theme(); - let spans = panel_title_spans("Chat", true, true, &theme); - assert_eq!(spans[0].content.as_ref(), "▌"); - assert_eq!( - spans[2].style, - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active - )) - .add_modifier(Modifier::BOLD) - ); - } - - #[test] - fn panel_title_spans_dim_when_unfocused() { - let theme = theme(); - let spans = panel_title_spans("Chat", true, false, &theme); - assert_eq!(spans[0].content.as_ref(), "▌"); - assert_eq!( - spans[2].style, - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active - )) - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::DIM) - ); - } - - #[test] - fn panel_hint_style_dims_when_inactive() { - let theme = theme(); - let style = panel_hint_style(false, &theme); - assert_eq!( - style.fg, - Some(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text - )) - ); - assert!(style.add_modifier.contains(Modifier::DIM)); - } - - #[test] - fn panel_hint_style_keeps_highlights_when_focused() { - let theme = theme(); - let style = panel_hint_style(true, &theme); - assert_eq!( - style.fg, - Some(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text - )) - ); - assert!(style.add_modifier.is_empty()); - } - - #[test] - fn border_style_matches_focus_state() { - let theme = theme(); - let focused = panel_border_style(true, true, &theme); - let active_unfocused = panel_border_style(true, false, &theme); - let inactive = panel_border_style(false, false, &theme); - assert_eq!( - focused.fg, - Some(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border - )) - ); - assert_eq!( - active_unfocused.fg, - Some(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border - )) - ); - assert_eq!( - inactive.fg, - Some(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border - )) - ); - assert!(inactive.add_modifier.contains(Modifier::DIM)); - } - - #[test] - fn breadcrumbs_include_repo_segments() { - let crumb = build_breadcrumbs("repo", Path::new("src/lib.rs")); - assert_eq!(crumb, "repo > src > lib.rs"); - } -} - -#[cfg(test)] -mod context_usage_tests { - use super::*; - #[test] - fn context_descriptor_formats_values() { - let usage = ContextUsage { - prompt_tokens: 2600, - completion_tokens: 0, - context_window: 8000, - }; - - let descriptor = context_usage_descriptor(usage).expect("descriptor should render"); - assert_eq!(descriptor.title, "Context"); - assert_eq!(descriptor.detail, "2.6k / 8k"); - assert_eq!(descriptor.percent_label, "33%"); - assert!((descriptor.ratio - 0.325).abs() < 1e-6); - assert_eq!(descriptor.band, ProgressBand::Normal); - } - - #[test] - fn context_descriptor_warns_near_limits() { - let usage = ContextUsage { - prompt_tokens: 7000, - completion_tokens: 0, - context_window: 10000, - }; - - let descriptor = context_usage_descriptor(usage).expect("descriptor should render"); - assert_eq!(descriptor.band, ProgressBand::Warning); - assert_eq!(descriptor.percent_label, "70%"); - } - - #[test] - fn context_descriptor_flags_danger_zone() { - let usage = ContextUsage { - prompt_tokens: 9000, - completion_tokens: 0, - context_window: 10000, - }; - - let descriptor = context_usage_descriptor(usage).expect("descriptor should render"); - assert_eq!(descriptor.band, ProgressBand::Critical); - assert_eq!(descriptor.percent_label, "90%"); - } - - #[test] - fn context_descriptor_handles_zero_usage() { - let usage = ContextUsage { - prompt_tokens: 0, - completion_tokens: 0, - context_window: 32000, - }; - - let descriptor = context_usage_descriptor(usage).expect("descriptor should render"); - assert_eq!(descriptor.detail, "0 / 32k"); - assert_eq!(descriptor.percent_label, "0%"); - assert_eq!(descriptor.band, ProgressBand::Normal); - } -} - -fn render_body_container( - frame: &mut Frame<'_>, - area: Rect, - palette: &GlassPalette, - layers: &LayerSettings, - reduced_chrome: bool, - layout_mode: AdaptiveLayout, -) -> Rect { - if area.width == 0 || area.height == 0 { - return area; - } - - let shadow_depth = if reduced_chrome { - 0 - } else { - layers.shadow_depth() - }; - - if shadow_depth > 0 && area.width > 2 && area.height > 2 { - for step in 1..=shadow_depth { - let offset = step as u16; - let shadow_area = Rect::new( - area.x.saturating_add(offset), - area.y.saturating_add(offset), - area.width.saturating_sub(offset), - area.height.saturating_sub(offset), - ); - if shadow_area.width == 0 || shadow_area.height == 0 { - continue; - } - let blend = (step as f64) / (shadow_depth as f64 + 1.5); - let shadow_color = blend_color(palette.shadow, palette.frosted, blend); - frame.render_widget( - Block::default().style(Style::default().bg(shadow_color)), - shadow_area, - ); - } - } - - frame.render_widget(Clear, area); - - let padding = if reduced_chrome { - Padding::new(1, 1, 0, 0) - } else { - let (pad_lr, pad_top, pad_bottom): (u16, u16, u16) = match layout_mode { - AdaptiveLayout::Compact => (1, 0, 0), - AdaptiveLayout::Cozy => (2, 1, 1), - AdaptiveLayout::Spacious => (3, 1, 2), - }; - Padding::new(pad_lr, pad_lr, pad_top, pad_bottom) - }; - - let block_background = if reduced_chrome { - palette.active - } else { - match layout_mode { - AdaptiveLayout::Compact => blend_color(palette.frosted, palette.highlight, 0.25), - AdaptiveLayout::Cozy => palette.frosted, - AdaptiveLayout::Spacious => blend_color(palette.frosted, palette.frost_edge, 0.35), - } - }; - - let block = Block::default() - .borders(Borders::NONE) - .padding(padding) - .style(Style::default().bg(block_background)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - if layers.focus_ring && !reduced_chrome && area.width > 1 && area.height > 1 { - let ring_mix = match layout_mode { - AdaptiveLayout::Compact => 0.25, - AdaptiveLayout::Cozy => 0.45, - AdaptiveLayout::Spacious => 0.6, - }; - let ring_color = blend_color(palette.focus_ring, palette.neon_glow, ring_mix); - draw_focus_ring(frame, area, ring_color); - } - inner -} - -fn draw_focus_ring(frame: &mut Frame<'_>, area: Rect, color: Color) { - if area.width == 0 || area.height == 0 { - return; - } - let buffer = frame.buffer_mut(); - let x0 = area.x; - let y0 = area.y; - let x1 = area.x + area.width.saturating_sub(1); - let y1 = area.y + area.height.saturating_sub(1); - - for x in x0..=x1 { - let cell_top = &mut buffer[(x, y0)]; - cell_top.set_bg(color); - if cell_top.symbol() == " " { - cell_top.set_fg(color); - } - if y1 != y0 { - let cell_bottom = &mut buffer[(x, y1)]; - cell_bottom.set_bg(color); - if cell_bottom.symbol() == " " { - cell_bottom.set_fg(color); - } - } - } - - for y in y0..=y1 { - let cell_left = &mut buffer[(x0, y)]; - cell_left.set_bg(color); - if cell_left.symbol() == " " { - cell_left.set_fg(color); - } - if x1 != x0 { - let cell_right = &mut buffer[(x1, y)]; - cell_right.set_bg(color); - if cell_right.symbol() == " " { - cell_right.set_fg(color); - } - } - } -} - -fn render_surface_glow( - frame: &mut Frame<'_>, - area: Rect, - palette: &GlassPalette, - intensity: f64, - layout_mode: AdaptiveLayout, -) { - if intensity <= 0.0 || area.width < 2 || area.height < 2 { - return; - } - let clamped = intensity.clamp(0.0, 1.0); - let mix = match layout_mode { - AdaptiveLayout::Compact => 0.35, - AdaptiveLayout::Cozy => 0.55, - AdaptiveLayout::Spacious => 0.7, - }; - let glow_color = blend_color(palette.frosted, palette.neon_glow, clamped * mix); - let buffer = frame.buffer_mut(); - let x0 = area.x; - let x1 = area.x + area.width.saturating_sub(1); - let y_top = area.y; - let y_second = if area.height > 2 { y_top + 1 } else { y_top }; - - for x in x0..=x1 { - buffer[(x, y_top)].set_bg(glow_color); - if y_second != y_top { - let secondary = blend_color(glow_color, palette.frosted, 0.5); - buffer[(x, y_second)].set_bg(secondary); - } - } -} - -fn apply_panel_glow(frame: &mut Frame<'_>, area: Rect, palette: &GlassPalette, intensity: f64) { - if intensity <= 0.0 || area.width == 0 || area.height == 0 { - return; - } - let clamped = intensity.clamp(0.0, 1.0); - let accent = blend_color(palette.frost_edge, palette.neon_accent, clamped); - let edge = blend_color(accent, palette.frosted, 0.5); - let buffer = frame.buffer_mut(); - let x0 = area.x; - let y0 = area.y; - let x1 = area.x + area.width.saturating_sub(1); - let y1 = area.y + area.height.saturating_sub(1); - - for x in x0..=x1 { - buffer[(x, y0)].set_bg(accent); - if y1 != y0 { - buffer[(x, y1)].set_bg(edge); - } - } - for y in y0..=y1 { - buffer[(x0, y)].set_bg(accent); - if x1 != x0 { - buffer[(x1, y)].set_bg(edge); - } - } -} - -fn render_chat_header( - frame: &mut Frame<'_>, - area: Rect, - app: &mut ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - frame.render_widget(Clear, area); - - let reduced = app.is_reduced_chrome(); - let header_block = Block::default() - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 0) - }) - .style(Style::default().bg(if reduced { - palette.active - } else { - palette.highlight - })); - let highlight_area = header_block.inner(area); - frame.render_widget(header_block, area); - - if highlight_area.width == 0 || highlight_area.height == 0 { - return; - } - - let mut constraints = vec![Constraint::Length(2)]; - if highlight_area.height > 2 { - constraints.push(Constraint::Min(2)); - } - - let rows = Layout::vertical(constraints) - .flex(Flex::Start) - .split(highlight_area); - - render_header_top(frame, rows[0], app, palette, theme); - - if rows.len() > 1 { - render_header_bars(frame, rows[1], app, palette, theme); - } -} - -fn render_header_top( - frame: &mut Frame<'_>, - area: Rect, - app: &ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let columns = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]) - .flex(Flex::SpaceBetween) - .split(area); - - let mut left_spans = Vec::new(); - left_spans.push(Span::styled( - format!(" 🦉 OWLEN v{APP_VERSION} "), - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - )) - .add_modifier(Modifier::BOLD), - )); - - let mode_label = match app.get_mode() { - owlen_core::mode::Mode::Chat => "Chat", - owlen_core::mode::Mode::Code => "Code", - }; - left_spans.push(Span::styled( - format!("· Mode {mode_label} "), - Style::default().fg(palette.label), - )); - - let focus_label = match app.focused_panel() { - FocusedPanel::Files => "Files", - FocusedPanel::Chat => "Chat", - FocusedPanel::Thinking => "Thinking", - FocusedPanel::Input => "Input", - FocusedPanel::Code => "Code", - }; - left_spans.push(Span::styled( - format!("· Focus {focus_label}"), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::ITALIC), - )); - - if app.is_agent_running() { - left_spans.push(Span::raw(" ")); - left_spans.push(Span::styled( - "🤖 RUN", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_running_fg, - )) - .bg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_running_bg, - )) - .add_modifier(Modifier::BOLD), - )); - } else if app.is_agent_mode() { - left_spans.push(Span::raw(" ")); - left_spans.push(Span::styled( - "🤖 ARM", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_idle_fg, - )) - .bg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_idle_bg, - )) - .add_modifier(Modifier::BOLD), - )); - } - - let left_line = spans_within_width(left_spans, columns[0].width); - frame.render_widget( - Paragraph::new(left_line).style(Style::default().bg(palette.highlight).fg(palette.label)), - columns[0], - ); - - let mut right_spans = Vec::new(); - let provider_display = truncate_with_ellipsis(app.current_provider(), 18); - right_spans.push(Span::styled( - provider_display, - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - )); - - let model_label = app.active_model_label(); - if !model_label.is_empty() { - let model_display = truncate_with_ellipsis(&model_label, 24); - right_spans.push(Span::styled( - format!(" · {model_display}"), - Style::default().fg(palette.label), - )); - } - - if app.is_loading() || app.is_streaming() { - let spinner = app.get_loading_indicator(); - let spinner = if spinner.is_empty() { "…" } else { spinner }; - right_spans.push(Span::styled( - format!(" · {spinner} streaming"), - Style::default().fg(progress_band_color(ProgressBand::Normal, theme)), - )); - } - - if let Some(flags) = app.accessibility_short_label() { - right_spans.push(Span::styled( - format!(" · Accessibility {flags}"), - Style::default().fg(palette.label), - )); - } - - let right_line = spans_within_width(right_spans, columns[1].width); - frame.render_widget( - Paragraph::new(right_line) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .alignment(Alignment::Right), - columns[1], - ); -} - -fn render_header_bars( - frame: &mut Frame<'_>, - area: Rect, - app: &mut ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - if area.height < 2 { - render_context_column(frame, area, app, palette, theme); - return; - } - - let legend_split = if app.should_render_accessibility_legend() && area.height > 2 { - let rows = Layout::vertical([Constraint::Min(2), Constraint::Length(1)]) - .flex(Flex::Start) - .split(area); - (rows[0], Some(rows[1])) - } else { - (area, None) - }; - - let columns = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]) - .flex(Flex::SpaceBetween) - .split(legend_split.0); - - { - let column = columns[0]; - render_context_column(frame, column, app, palette, theme); - } - { - let column = columns[1]; - render_usage_column(frame, column, app, palette, theme); - } - - if let Some(legend_area) = legend_split.1 { - render_accessibility_legend(frame, legend_area, app, palette); - } -} - -fn render_context_column( - frame: &mut Frame<'_>, - area: Rect, - app: &mut ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let descriptor = app - .context_usage_with_fallback() - .and_then(context_usage_descriptor); - - match descriptor { - Some(descriptor) => { - let display_ratio = app.animated_gauge_ratio(GaugeKey::Context, descriptor.ratio); - if area.height < 2 { - render_gauge_compact(frame, area, &descriptor, palette); - } else { - render_gauge( - frame, - area, - &descriptor, - display_ratio, - &palette.context_stops, - palette, - theme, - ); - } - } - None => { - app.reset_gauge(GaugeKey::Context); - frame.render_widget( - Paragraph::new("Context metrics not available") - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - area, - ); - } - } -} - -fn render_usage_column( - frame: &mut Frame<'_>, - area: Rect, - app: &mut ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let mut descriptors: Vec<(GaugeKey, GaugeDescriptor)> = Vec::new(); - let mut hour_present = false; - let mut week_present = false; - - if let Some(snapshot) = app.usage_snapshot() { - if let Some(descriptor) = usage_gauge_descriptor(snapshot, UsageWindow::Hour) { - hour_present = true; - descriptors.push((GaugeKey::UsageHour, descriptor)); - } - if let Some(descriptor) = usage_gauge_descriptor(snapshot, UsageWindow::Week) { - week_present = true; - descriptors.push((GaugeKey::UsageWeek, descriptor)); - } - } - - if !hour_present { - app.reset_gauge(GaugeKey::UsageHour); - } - if !week_present { - app.reset_gauge(GaugeKey::UsageWeek); - } - - if descriptors.is_empty() { - frame.render_widget( - Paragraph::new("Cloud usage pending") - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - area, - ); - return; - } - - let bottom = area.y.saturating_add(area.height); - let mut cursor_y = area.y; - - for (key, descriptor) in descriptors { - if cursor_y >= bottom { - break; - } - let remaining = bottom - cursor_y; - if remaining < 2 { - frame.render_widget( - Paragraph::new(format!("{} {}", descriptor.title, descriptor.percent_label)) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - Rect::new(area.x, cursor_y, area.width, remaining), - ); - break; - } - - let gauge_area = Rect::new(area.x, cursor_y, area.width, 2); - let display_ratio = app.animated_gauge_ratio(key, descriptor.ratio); - render_gauge( - frame, - gauge_area, - &descriptor, - display_ratio, - &palette.usage_stops, - palette, - theme, - ); - cursor_y = cursor_y.saturating_add(2); - } -} - -fn render_accessibility_legend( - frame: &mut Frame<'_>, - area: Rect, - app: &ChatApp, - palette: &GlassPalette, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let mut legend_text = "Legend · Normal <60% · Warning 60–85% · Critical >85%".to_string(); - if let Some(flags) = app.accessibility_short_label() { - legend_text.push_str(&format!(" · Modes {flags}")); - } - frame.render_widget( - Paragraph::new(legend_text) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn render_gauge( - frame: &mut Frame<'_>, - area: Rect, - descriptor: &GaugeDescriptor, - display_ratio: f64, - stops: &[Color; 3], - palette: &GlassPalette, - theme: &Theme, -) { - if area.height < 2 || area.width < 4 { - render_gauge_compact(frame, area, descriptor, palette); - return; - } - - let label_color = progress_band_color(descriptor.band, theme); - - let label_area = Rect::new(area.x, area.y, area.width, 1); - let bar_area = Rect::new(area.x, area.y + 1, area.width, 1); - - let label_line = spans_within_width( - vec![ - Span::styled( - descriptor.title.clone(), - Style::default() - .fg(label_color) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - descriptor.detail.clone(), - Style::default().fg(palette.label), - ), - Span::styled( - descriptor.percent_label.clone(), - Style::default() - .fg(label_color) - .add_modifier(Modifier::BOLD), - ), - ], - label_area.width, - ); - - frame.render_widget( - Paragraph::new(label_line).style(Style::default().bg(palette.highlight).fg(palette.label)), - label_area, - ); - - draw_gradient_bar( - frame, - bar_area, - display_ratio, - stops, - palette, - &descriptor.percent_label, - ); -} - -fn render_gauge_compact( - frame: &mut Frame<'_>, - area: Rect, - descriptor: &GaugeDescriptor, - palette: &GlassPalette, -) { - if area.width == 0 || area.height == 0 { - return; - } - - frame.render_widget( - Paragraph::new(format!( - "{} · {} ({})", - descriptor.title, descriptor.detail, descriptor.percent_label - )) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn draw_gradient_bar( - frame: &mut Frame<'_>, - area: Rect, - ratio: f64, - stops: &[Color; 3], - palette: &GlassPalette, - percent_label: &str, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let ratio = ratio.clamp(0.0, 1.0); - let width = area.width; - let mut filled_width = ((width as f64) * ratio).round() as u16; - filled_width = filled_width.min(width); - - { - let buffer = frame.buffer_mut(); - for offset in 0..width { - let x = area.x + offset; - let is_filled = offset < filled_width; - let color = if filled_width == 0 || !is_filled { - palette.track - } else if filled_width <= 1 { - gradient_color(stops, ratio) - } else { - let segment = offset.min(filled_width - 1); - let pos = segment as f64 / (filled_width - 1) as f64; - gradient_color(stops, pos) - }; - - buffer[(x, area.y)] - .set_symbol(" ") - .set_bg(color) - .set_fg(color); - } - } - - let buffer = frame.buffer_mut(); - let label_chars: Vec = percent_label.chars().collect(); - if !label_chars.is_empty() && label_chars.len() as u16 <= width { - let start = area.x + (width - label_chars.len() as u16) / 2; - for (idx, ch) in label_chars.iter().enumerate() { - let x = start + idx as u16; - if x >= area.x + width { - break; - } - buffer[(x, area.y)] - .set_symbol(&ch.to_string()) - .set_fg(palette.label); - } - } -} - -pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { - // Update thinking content from last message - app.update_thinking_from_last_message(); - - // Set terminal background color - let theme = app.theme().clone(); - let palette = - GlassPalette::for_theme_with_mode(&theme, app.is_reduced_chrome(), app.layer_settings()); - let frame_area = frame.area(); - frame.render_widget( - Block::default() - .style(Style::default().bg(crate::color_convert::to_ratatui_color(&theme.background))), - frame_area, - ); - - let layout_hint = AdaptiveLayout::from_width(frame_area.width); - - let mut header_height = match layout_hint { - AdaptiveLayout::Compact => 4, - AdaptiveLayout::Cozy => { - if frame_area.height >= 14 { - 5 - } else { - 4 - } - } - AdaptiveLayout::Spacious => { - if frame_area.height >= 14 { - 6 - } else { - 4 - } - } - }; - if frame_area.height <= header_height { - header_height = frame_area.height.saturating_sub(1); - } - let header_height = header_height.max(3).min(frame_area.height); - - let segments = if frame_area.height <= header_height { - Layout::vertical([Constraint::Length(frame_area.height)]) - .flex(Flex::Start) - .split(frame_area) - } else { - Layout::vertical([Constraint::Length(header_height), Constraint::Min(1)]) - .flex(Flex::Start) - .split(frame_area) - }; - - if segments.is_empty() { - app.set_layout_snapshot(LayoutSnapshot::new(frame_area, frame_area)); - return; - } - - let (header_area, body_area) = if segments.len() == 1 { - (Rect::new(0, 0, 0, 0), segments[0]) - } else { - (segments[0], segments[1]) - }; - - if header_area.width > 0 && header_area.height > 0 { - render_chat_header(frame, header_area, app, &palette, &theme); - } - - let content_area = render_body_container( - frame, - body_area, - &palette, - app.layer_settings(), - app.is_reduced_chrome(), - layout_hint, - ); - let final_layout_mode = AdaptiveLayout::from_width(content_area.width); - app.update_layout_mode(final_layout_mode); - let stage_glow = app.pane_glow(PanePulse::Stage); - render_surface_glow(frame, content_area, &palette, stage_glow, final_layout_mode); - - let mut snapshot = LayoutSnapshot::new(frame_area, content_area); - snapshot.layout_mode = final_layout_mode; - snapshot.header_panel = if header_area.width > 0 && header_area.height > 0 { - Some(header_area) - } else { - None - }; - - if content_area.width == 0 || content_area.height == 0 { - app.set_layout_snapshot(snapshot); - return; - } - - let file_panel_glow = app.pane_glow(PanePulse::FilePanel); - let code_panel_glow = app.pane_glow(PanePulse::CodePanel); - let model_panel_glow = app.pane_glow(PanePulse::ModelPanel); - let debug_panel_glow = app.pane_glow(PanePulse::DebugPanel); - - if !app.is_code_mode() && !app.is_file_panel_collapsed() { - app.set_file_panel_collapsed(true); - } - - let show_file_panel = app.is_code_mode() - && !app.is_file_panel_collapsed() - && !matches!(final_layout_mode, AdaptiveLayout::Compact) - && content_area.width >= 40; - - let (file_area, main_area) = if !show_file_panel { - (None, content_area) - } else { - let max_sidebar = content_area.width.saturating_sub(30).max(10); - let sidebar_width = app.file_panel_width().min(max_sidebar).max(10); - let segments = Layout::horizontal([Constraint::Length(sidebar_width), Constraint::Min(30)]) - .flex(Flex::Start) - .split(content_area); - (Some(segments[0]), segments[1]) - }; - - let (chat_area, code_area) = if app.should_show_code_view() { - match final_layout_mode { - AdaptiveLayout::Spacious => { - let segments = - Layout::horizontal([Constraint::Percentage(65), Constraint::Percentage(35)]) - .flex(Flex::Start) - .split(main_area); - (segments[0], Some(segments[1])) - } - _ => { - let (chat_pct, code_pct) = if matches!(final_layout_mode, AdaptiveLayout::Compact) { - (55, 45) - } else { - (62, 38) - }; - let segments = Layout::vertical([ - Constraint::Percentage(chat_pct), - Constraint::Percentage(code_pct), - ]) - .flex(Flex::Start) - .split(main_area); - (segments[0], Some(segments[1])) - } - } - } else { - (main_area, None) - }; - - if let Some(file_area) = file_area { - snapshot.file_panel = Some(file_area); - render_file_tree(frame, file_area, app); - if file_panel_glow > 0.0 { - apply_panel_glow(frame, file_area, &palette, file_panel_glow); - } - } - - // Calculate dynamic input height based on textarea content - let available_width = chat_area.width; - let max_input_rows = usize::from(app.input_max_rows()).max(1); - let visual_lines = if matches!(app.mode(), InputMode::Editing | InputMode::Visual) { - calculate_wrapped_line_count( - app.textarea().lines().iter().map(|s| s.as_str()), - available_width, - ) - } else { - let buffer_text = app.input_buffer_text(); - let lines: Vec<&str> = if buffer_text.is_empty() { - vec![""] - } else { - buffer_text.split('\n').collect() - }; - calculate_wrapped_line_count(lines, available_width) - }; - let visible_rows = visual_lines.max(1).min(max_input_rows); - let input_height = visible_rows as u16 + 2; // +2 for borders - - // Calculate thinking section height - let thinking_height = if let Some(thinking) = app.current_thinking() { - let content_width = available_width.saturating_sub(4); - let visual_lines = calculate_wrapped_line_count(thinking.lines(), content_width); - (visual_lines as u16).min(6) + 2 // +2 for borders, max 6 lines - } else { - 0 - }; - - // Calculate agent actions panel height (similar to thinking) - let actions_height = if let Some(actions) = app.agent_actions() { - let content_width = available_width.saturating_sub(4); - let visual_lines = calculate_wrapped_line_count(actions.lines(), content_width); - (visual_lines as u16).min(6) + 2 // +2 for borders, max 6 lines - } else { - 0 - }; - - let status_message = system_status_message(app); - - let mut constraints = vec![Constraint::Min(8)]; // Messages - - let attachments_height = app.attachment_preview_height(); - if attachments_height > 0 { - constraints.push(Constraint::Length(attachments_height)); // Attachments - } - - if thinking_height > 0 { - constraints.push(Constraint::Length(thinking_height)); // Thinking - } - // Insert agent actions panel after thinking (if any) - if actions_height > 0 { - constraints.push(Constraint::Length(actions_height)); // Agent actions - } - - constraints.push(Constraint::Length(input_height)); // Input - - let status_visual_lines = calculate_wrapped_line_count(status_message.lines(), available_width); - let status_visible_rows = status_visual_lines.clamp(1, 5); - let status_height = status_visible_rows as u16 + 2; // +2 for borders - - constraints.push(Constraint::Length(status_height)); // System/Status output (dynamic) - constraints.push(Constraint::Length(3)); // Mode and shortcuts bar - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(chat_area); - - let mut idx = 0; - snapshot.chat_panel = Some(layout[idx]); - render_messages(frame, layout[idx], app); - idx += 1; - - if attachments_height > 0 { - snapshot.attachments_panel = Some(layout[idx]); - render_attachment_preview(frame, layout[idx], app); - idx += 1; - } else { - snapshot.attachments_panel = None; - } - - if thinking_height > 0 { - snapshot.thinking_panel = Some(layout[idx]); - render_thinking(frame, layout[idx], app); - idx += 1; - } else { - snapshot.thinking_panel = None; - } - // Render agent actions panel if present - if actions_height > 0 { - snapshot.actions_panel = Some(layout[idx]); - render_agent_actions(frame, layout[idx], app); - idx += 1; - } else { - snapshot.actions_panel = None; - } - - snapshot.input_panel = Some(layout[idx]); - render_input(frame, layout[idx], app); - idx += 1; - - snapshot.system_panel = Some(layout[idx]); - render_system_output(frame, layout[idx], app, &status_message); - idx += 1; - - snapshot.status_panel = Some(layout[idx]); - render_status(frame, layout[idx], app); - - // Render consent dialog with highest priority (always on top) - if app.has_pending_consent() { - render_consent_dialog(frame, app); - } else { - match app.mode() { - InputMode::ProviderSelection => render_provider_selector(frame, app), - InputMode::ModelSelection => render_model_picker(frame, app), - InputMode::Help => render_help(frame, app), - InputMode::SessionBrowser => render_session_browser(frame, app), - InputMode::ThemeBrowser => render_theme_browser(frame, app), - InputMode::Command => render_command_suggestions(frame, app), - InputMode::RepoSearch => render_repo_search(frame, app), - InputMode::SymbolSearch => render_symbol_search(frame, app), - _ => {} - } - } - - if app.is_model_info_visible() { - let panel_width = content_area - .width - .saturating_div(3) - .max(30) - .min(content_area.width.saturating_sub(20).max(30)) - .min(content_area.width); - let x = content_area - .x - .saturating_add(content_area.width.saturating_sub(panel_width)); - let area = Rect::new(x, content_area.y, panel_width, content_area.height); - snapshot.model_info_panel = Some(area); - frame.render_widget(Clear, area); - let viewport_height = area.height.saturating_sub(2) as usize; - app.set_model_info_viewport_height(viewport_height); - app.model_info_panel_mut().render(frame, area, &theme); - if model_panel_glow > 0.0 { - apply_panel_glow(frame, area, &palette, model_panel_glow); - } - } else { - snapshot.model_info_panel = None; - } - - if let Some(area) = code_area { - snapshot.code_panel = Some(area); - render_code_workspace(frame, area, app); - if code_panel_glow > 0.0 { - apply_panel_glow(frame, area, &palette, code_panel_glow); - } - } else { - snapshot.code_panel = None; - } - - if app.is_debug_log_visible() { - let min_height = 6; - let computed_height = content_area.height.saturating_div(3).max(min_height); - let panel_height = computed_height.min(content_area.height); - - if panel_height >= 4 { - let y = content_area - .y - .saturating_add(content_area.height.saturating_sub(panel_height)); - let log_area = Rect::new(content_area.x, y, content_area.width, panel_height); - render_debug_log_panel(frame, log_area, app); - if debug_panel_glow > 0.0 { - apply_panel_glow(frame, log_area, &palette, debug_panel_glow); - } - } - } - - app.set_layout_snapshot(snapshot); - render_toasts(frame, app, content_area); -} - -fn toast_palette(level: ToastLevel, theme: &Theme) -> (&'static str, Style, Style) { - let (label, color) = match level { - ToastLevel::Info => ("INFO", to_ratatui_color(&theme.info)), - ToastLevel::Success => ("OK", to_ratatui_color(&theme.agent_badge_idle_bg)), - ToastLevel::Warning => ("WARN", to_ratatui_color(&theme.agent_action)), - ToastLevel::Error => ("ERROR", to_ratatui_color(&theme.error)), - }; - - let badge_style = Style::default() - .fg(to_ratatui_color(&theme.background)) - .bg(color) - .add_modifier(Modifier::BOLD); - let border_style = Style::default().fg(color); - (label, badge_style, border_style) -} - -fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) { - let toasts: Vec<&Toast> = app.toasts().collect(); - if toasts.is_empty() { - return; - } - - let theme = app.theme(); - let now = Instant::now(); - let available_width = usize::from(full_area.width.saturating_sub(2)); - if available_width == 0 { - return; - } - - let width = available_width.clamp(24, 52) as u16; - - let offset_x = full_area - .x - .saturating_add(full_area.width.saturating_sub(width + 1)); - let mut offset_y = full_area.y.saturating_add(1); - let frame_bottom = full_area.y.saturating_add(full_area.height); - - for toast in toasts { - let (label, badge_style, border_style) = toast_palette(toast.level, theme); - let accent_color = toast_level_color(toast.level, theme); - let icon = toast_icon(toast.level); - let badge_text = format!(" {} ", label); - let indent_width = UnicodeWidthStr::width(badge_text.as_str()) + 2; - let indent = " ".repeat(indent_width); - - let content_width = width.saturating_sub(4).max(1) as usize; - let wrapped_lines = wrap(toast.message.as_str(), content_width); - let lines: Vec = if wrapped_lines.is_empty() { - vec![String::new()] - } else { - wrapped_lines - .into_iter() - .map(|cow| cow.into_owned()) - .collect() - }; - - let mut paragraph_lines = Vec::new(); - if let Some((first, rest)) = lines.split_first() { - paragraph_lines.push(Line::from(vec![ - Span::styled(badge_text.clone(), badge_style), - Span::styled( - format!(" {icon} "), - Style::default() - .fg(to_ratatui_color(&accent_color)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - first.clone(), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ])); - for line in rest { - paragraph_lines.push(Line::from(vec![ - Span::raw(indent.clone()), - Span::styled( - line.clone(), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ])); - } - } - - if let Some(hint) = toast.shortcut().filter(|hint| !hint.is_empty()) { - paragraph_lines.push(Line::from(vec![ - Span::raw(indent.clone()), - Span::styled( - hint.to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - ), - ])); - } - - let bar_width = content_width.clamp(6, 24); - let remaining_fraction = toast.remaining_fraction(now) as f64; - let bar = unicode_progress_bar(bar_width, remaining_fraction); - let remaining_secs = toast.remaining_duration(now).as_secs().min(99); - paragraph_lines.push(Line::from(vec![ - Span::raw(indent.clone()), - Span::styled(bar, Style::default().fg(to_ratatui_color(&accent_color))), - Span::raw(format!(" {:>2}s", remaining_secs)), - ])); - - let height = (paragraph_lines.len() as u16).saturating_add(2); - if offset_y.saturating_add(height) > frame_bottom { - break; - } - - let area = Rect::new(offset_x, offset_y, width, height); - frame.render_widget(Clear, area); - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .style(Style::default().bg(crate::color_convert::to_ratatui_color(&theme.background))); - let paragraph = Paragraph::new(paragraph_lines) - .block(block) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }); - frame.render_widget(paragraph, area); - - offset_y = offset_y.saturating_add(height + 1); - if offset_y >= frame_bottom { - break; - } - } -} - -#[derive(Debug, Clone)] -struct TreeLineRenderInfo { - ancestor_has_sibling: Vec, - is_last_sibling: bool, -} - -fn compute_tree_line_info( - entries: &[VisibleFileEntry], - nodes: &[FileNode], -) -> Vec { - let mut info = Vec::with_capacity(entries.len()); - let mut sibling_stack: Vec = Vec::new(); - - for (index, entry) in entries.iter().enumerate() { - let depth = entry.depth; - if sibling_stack.len() >= depth { - sibling_stack.truncate(depth); - } - - let is_last = is_last_visible_sibling(entries, nodes, index); - info.push(TreeLineRenderInfo { - ancestor_has_sibling: sibling_stack.clone(), - is_last_sibling: is_last, - }); - - sibling_stack.push(!is_last); - } - - info -} - -fn is_last_visible_sibling( - entries: &[VisibleFileEntry], - nodes: &[FileNode], - position: usize, -) -> bool { - let depth = entries[position].depth; - let node_index = entries[position].index; - let parent = nodes[node_index].parent; - - for next in entries.iter().skip(position + 1) { - if next.depth < depth { - break; - } - if next.depth == depth && nodes[next.index].parent == parent { - return false; - } - } - true -} - -fn collect_unsaved_relative_paths(app: &ChatApp, root: &Path) -> HashSet { - let mut set = HashSet::new(); - for pane in app.workspace().panes() { - if !pane.is_dirty { - continue; - } - if let Some(abs) = pane.absolute_path() - && let Some(rel) = diff_paths(abs, root) - { - set.insert(rel); - continue; - } - if let Some(display) = pane.display_path() { - let display_path = PathBuf::from(display); - if display_path.is_relative() { - set.insert(display_path); - } - } - } - set -} - -fn build_breadcrumbs(repo_name: &str, path: &Path) -> String { - let mut parts = vec![repo_name.to_string()]; - for component in path.components() { - if let Component::Normal(segment) = component - && !segment.is_empty() - { - parts.push(segment.to_string_lossy().into_owned()); - } - } - parts.join(" > ") -} - -fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let theme = app.theme().clone(); - let has_focus = matches!(app.focused_panel(), FocusedPanel::Files); - let (repo_name, filter_query, filter_mode, show_hidden) = { - let tree = app.file_tree(); - ( - tree.repo_name().to_string(), - tree.filter_query().to_string(), - tree.filter_mode(), - tree.show_hidden(), - ) - }; - let mut title_spans = - panel_title_spans(format!("Files ▸ {}", repo_name), true, has_focus, &theme); - - if !filter_query.is_empty() { - let mode_label = match filter_mode { - FileFilterMode::Glob => "glob", - FileFilterMode::Fuzzy => "fuzzy", - }; - title_spans.push(Span::raw(" ")); - title_spans.push(Span::styled( - format!("{}:{}", mode_label, filter_query), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - )); - } - - if show_hidden { - title_spans.push(Span::raw(" ")); - title_spans.push(Span::styled( - "hidden:on", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text, - )) - .add_modifier(Modifier::ITALIC), - )); - } - - title_spans.push(Span::styled( - " ↩ open · Ctrl+1 focus · o split↓ · O split→ · t tab · y abs · Y rel · A dir · r ren · m move · d del · . $EDITOR · gh hidden · / fuzzy search", - panel_hint_style(has_focus, &theme), - )); - - let block = Block::default() - .title(Line::from(title_spans)) - .borders(Borders::ALL) - .border_style(panel_border_style(true, has_focus, &theme)) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ); - - let inner = block.inner(area); - let viewport_height = inner.height as usize; - - if viewport_height == 0 || inner.width == 0 { - frame.render_widget(block, area); - return; - } - - { - let tree = app.file_tree_mut(); - tree.set_viewport_height(viewport_height); - } - - let root_path = { - let tree = app.file_tree(); - tree.root().to_path_buf() - }; - let unsaved_paths = collect_unsaved_relative_paths(app, &root_path); - - let tree = app.file_tree(); - let git_enabled = app.is_code_mode(); - let entries = tree.visible_entries(); - let render_info = compute_tree_line_info(entries, tree.nodes()); - let icon_resolver = app.file_icons(); - let start = tree.scroll_top().min(entries.len()); - let end = (start + viewport_height).min(entries.len()); - let error_message = tree.last_error().map(|msg| msg.to_string()); - - let mut items = Vec::new(); - - if let Some((prompt_text, is_destructive)) = app.file_panel_prompt_text() { - let prompt_style = if is_destructive { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.error)) - .add_modifier(Modifier::BOLD | Modifier::ITALIC) - } else { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::ITALIC) - }; - items.push(ListItem::new(Line::from(vec![Span::styled( - prompt_text, - prompt_style, - )]))); - } - if start >= end { - items.push( - ListItem::new(Line::from(vec![Span::styled( - "No files", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )])) - .style(Style::default()), - ); - } else { - let mut guide_style = - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)); - if !has_focus { - guide_style = guide_style.add_modifier(Modifier::DIM); - } - - for (offset, entry) in entries[start..end].iter().enumerate() { - let absolute_idx = start + offset; - let is_selected = absolute_idx == tree.cursor(); - let node = &tree.nodes()[entry.index]; - let info = &render_info[absolute_idx]; - let mut spans: Vec> = Vec::new(); - - spans.push(focus_beacon_span(is_selected, has_focus, &theme)); - spans.push(Span::raw(" ")); - - for &has_more in &info.ancestor_has_sibling { - let glyph = if has_more { "│" } else { " " }; - spans.push(Span::styled(format!("{glyph} "), guide_style)); - } - - if entry.depth > 0 { - let branch = if info.is_last_sibling { - "└─" - } else { - "├─" - }; - spans.push(Span::styled(branch.to_string(), guide_style)); - } - - let toggle_symbol = if node.is_dir { - if node.children.is_empty() { - " " - } else if node.is_expanded { - "▾ " - } else { - "▸ " - } - } else { - " " - }; - spans.push(Span::styled(toggle_symbol.to_string(), guide_style)); - - let mut git_color: Option = None; - let mut git_modifiers = Modifier::empty(); - if git_enabled { - git_color = match node.git.badge { - Some('D') => Some(Color::LightRed), - Some('A') => Some(Color::LightGreen), - Some('R') | Some('C') => Some(Color::Yellow), - Some('U') => Some(Color::Magenta), - Some('M') => Some(Color::Yellow), - _ => None, - }; - if let Some('D') = node.git.badge { - git_modifiers |= Modifier::ITALIC; - } - if let Some('U') = node.git.badge { - git_modifiers |= Modifier::BOLD; - } - if git_color.is_none() { - git_color = match node.git.cleanliness { - '○' => Some(Color::LightYellow), - '●' => Some(Color::Yellow), - _ => None, - }; - } - } - - let mut icon_style = if node.is_dir { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)) - } else { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)) - }; - if let Some(color) = git_color { - icon_style = icon_style.fg(color); - } - if !has_focus && !is_selected { - icon_style = icon_style.add_modifier(Modifier::DIM); - } - if node.is_hidden { - icon_style = icon_style.add_modifier(Modifier::DIM); - } - let icon = icon_resolver.icon_for(node); - spans.push(Span::styled(format!("{icon} "), icon_style)); - - let is_unsaved = !node.is_dir && unsaved_paths.contains(&node.path); - let mut name_style = - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)); - if let Some(color) = git_color { - name_style = name_style.fg(color); - } - if node.is_dir { - name_style = name_style.add_modifier(Modifier::BOLD); - } - if node.is_hidden { - name_style = name_style.add_modifier(Modifier::DIM); - } - if is_unsaved { - name_style = name_style.add_modifier(Modifier::ITALIC); - } - if !git_modifiers.is_empty() { - name_style = name_style.add_modifier(git_modifiers); - } - - spans.push(Span::styled(node.name.clone(), name_style)); - - let mut marker_spans: Vec> = Vec::new(); - if git_enabled && is_unsaved { - marker_spans.push(Span::styled( - "~", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.error)) - .add_modifier(Modifier::BOLD), - )); - } - if node.is_hidden && show_hidden { - marker_spans.push(Span::styled( - "gh", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text, - )) - .add_modifier(Modifier::DIM | Modifier::ITALIC), - )); - } - if git_enabled { - if node.git.cleanliness != '✓' { - let marker_color = git_color.unwrap_or(to_ratatui_color(&theme.info)); - marker_spans.push(Span::styled("*", Style::default().fg(marker_color))); - } - if let Some(badge) = node.git.badge { - let marker_color = git_color.unwrap_or(to_ratatui_color(&theme.info)); - marker_spans.push(Span::styled( - badge.to_string(), - Style::default().fg(marker_color), - )); - } - } - - if !marker_spans.is_empty() { - spans.push(Span::raw(" ")); - for (idx, marker) in marker_spans.into_iter().enumerate() { - if idx > 0 { - spans.push(Span::raw(" ")); - } - spans.push(marker); - } - } - - let mut line_style = Style::default(); - if is_selected { - line_style = line_style - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)); - } else if !has_focus { - line_style = line_style.add_modifier(Modifier::DIM); - } - - items.push(ListItem::new(Line::from(spans)).style(line_style)); - } - } - - if let Some(err) = error_message { - items.insert( - 0, - ListItem::new(Line::from(vec![Span::styled( - format!("⚠ {err}"), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.error)) - .add_modifier(Modifier::BOLD | Modifier::ITALIC), - )])) - .style(Style::default()), - ); - } - - let list = List::new(items).block(block); - frame.render_widget(list, area); -} - -fn render_editable_textarea( - frame: &mut Frame<'_>, - area: Rect, - textarea: &mut TextArea<'static>, - mut wrap_lines: bool, - show_cursor: bool, - theme: &Theme, -) { - let block = textarea.block().cloned(); - let inner = block.as_ref().map(|b| b.inner(area)).unwrap_or(area); - let base_style = textarea.style(); - let cursor_line_style = textarea.cursor_line_style(); - let selection_style = textarea.selection_style(); - let selection_range = textarea.selection_range(); - let cursor = textarea.cursor(); - let mask_char = textarea.mask_char(); - let is_empty = textarea.is_empty(); - let placeholder_text = textarea.placeholder_text().to_string(); - let placeholder_style = textarea.placeholder_style(); - let lines_slice = textarea.lines(); - - // Disable wrapping when there's an active selection to preserve highlighting - if selection_range.is_some() { - wrap_lines = false; - } - - let mut render_lines: Vec = Vec::new(); - - if is_empty { - if !placeholder_text.is_empty() { - let style = placeholder_style.unwrap_or_else(|| { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - }); - render_lines.push(Line::from(vec![Span::styled(placeholder_text, style)])); - } else { - render_lines.push(Line::default()); - } - } else { - for (row_idx, raw_line) in lines_slice.iter().enumerate() { - let display_line = mask_char - .map(|mask| mask_line(raw_line, mask)) - .unwrap_or_else(|| raw_line.clone()); - - let spans = build_line_spans(&display_line, row_idx, selection_range, selection_style); - - let mut line = Line::from(spans); - if row_idx == cursor.0 { - line = line.patch_style(cursor_line_style); - } - render_lines.push(line); - } - } - - if render_lines.is_empty() { - render_lines.push(Line::default()); - } - - // If wrapping is enabled, we need to manually wrap the lines - // This ensures consistency with cursor calculation - if wrap_lines { - let content_width = inner.width as usize; - let mut wrapped_lines: Vec = Vec::new(); - - for (row_idx, line) in render_lines.iter().enumerate() { - let line_text = line.to_string(); - let segments = wrap_line_segments(&line_text, content_width); - - for (seg_idx, segment) in segments.into_iter().enumerate() { - // For the line with the cursor, preserve the cursor line style - if row_idx == cursor.0 && seg_idx == 0 { - wrapped_lines.push(Line::from(segment).patch_style(cursor_line_style)); - } else { - wrapped_lines.push(Line::from(segment)); - } - } - } - - render_lines = wrapped_lines; - } - - let mut paragraph = Paragraph::new(render_lines).style(base_style); - - let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines); - - if let Some(metrics) = metrics - .as_ref() - .filter(|metrics| metrics.scroll_top > 0 || metrics.scroll_left > 0) - { - paragraph = paragraph.scroll((metrics.scroll_top, metrics.scroll_left)); - } - - if let Some(block) = block { - paragraph = paragraph.block(block); - } - - frame.render_widget(paragraph, area); - - if let Some(metrics) = metrics.filter(|_| show_cursor) { - frame.set_cursor_position((metrics.cursor_x, metrics.cursor_y)); - } -} - -fn mask_line(line: &str, mask: char) -> String { - line.chars().map(|_| mask).collect() -} - -fn build_line_spans( - display_line: &str, - row_idx: usize, - selection: Option<((usize, usize), (usize, usize))>, - selection_style: Style, -) -> Vec> { - if let Some(((start_row, start_col), (end_row, end_col))) = selection { - if row_idx < start_row || row_idx > end_row { - return vec![Span::raw(display_line.to_string())]; - } - - let char_count = display_line.chars().count(); - let start = if row_idx == start_row { - start_col.min(char_count) - } else { - 0 - }; - let end = if row_idx == end_row { - end_col.min(char_count) - } else { - char_count - }; - - if start >= end { - return vec![Span::raw(display_line.to_string())]; - } - - let start_byte = char_to_byte_idx(display_line, start); - let end_byte = char_to_byte_idx(display_line, end); - - let mut spans = Vec::new(); - if start_byte > 0 { - spans.push(Span::raw(display_line[..start_byte].to_string())); - } - spans.push(Span::styled( - display_line[start_byte..end_byte].to_string(), - selection_style, - )); - if end_byte < display_line.len() { - spans.push(Span::raw(display_line[end_byte..].to_string())); - } - if spans.is_empty() { - spans.push(Span::raw(String::new())); - } - spans - } else { - vec![Span::raw(display_line.to_string())] - } -} - -fn char_to_byte_idx(s: &str, char_idx: usize) -> usize { - if char_idx == 0 { - return 0; - } - - let mut iter = s.char_indices(); - for (i, (byte_idx, _)) in iter.by_ref().enumerate() { - if i == char_idx { - return byte_idx; - } - } - s.len() -} - -struct CursorMetrics { - cursor_x: u16, - cursor_y: u16, - scroll_top: u16, - scroll_left: u16, -} - -fn compute_cursor_metrics( - lines: &[String], - cursor: (usize, usize), - mask_char: Option, - inner: Rect, - wrap_lines: bool, -) -> Option { - if inner.width == 0 || inner.height == 0 { - return None; - } - - let content_width = inner.width as usize; - let visible_height = inner.height as usize; - if content_width == 0 || visible_height == 0 { - return None; - } - - let cursor_row = cursor.0.min(lines.len().saturating_sub(1)); - let cursor_col = cursor.1; - - let mut total_visual_rows = 0usize; - let mut cursor_visual_row = 0usize; - let mut cursor_col_width = 0usize; - let mut cursor_found = false; - let mut cursor_line_total_width = 0usize; - - for (row_idx, line) in lines.iter().enumerate() { - let display_owned = mask_char.map(|mask| mask_line(line, mask)); - let display_line = display_owned.as_deref().unwrap_or(line.as_str()); - - let mut segments = if wrap_lines { - wrap_line_segments(display_line, content_width) - } else { - vec![display_line.to_string()] - }; - - if segments.is_empty() { - segments.push(String::new()); - } - - if row_idx == cursor_row && !cursor_found { - cursor_line_total_width = segments - .iter() - .map(|segment| UnicodeWidthStr::width(segment.as_str())) - .sum(); - - let mut remaining = cursor_col; - let mut segment_base_row = total_visual_rows; - for (segment_idx, segment) in segments.iter().enumerate() { - let segment_len = segment.chars().count(); - let is_last_segment = segment_idx + 1 == segments.len(); - - if remaining > segment_len { - remaining -= segment_len; - segment_base_row += 1; - continue; - } - - if remaining == segment_len && !is_last_segment { - cursor_visual_row = segment_base_row + 1; - cursor_col_width = 0; - cursor_found = true; - break; - } - - let prefix_byte = char_to_byte_idx(segment, remaining); - let prefix = &segment[..prefix_byte]; - cursor_visual_row = segment_base_row; - cursor_col_width = UnicodeWidthStr::width(prefix); - cursor_found = true; - break; - } - - if !cursor_found - && let Some(last_segment) = segments.last() - { - cursor_visual_row = segment_base_row + segments.len().saturating_sub(1); - cursor_col_width = UnicodeWidthStr::width(last_segment.as_str()); - cursor_found = true; - } - } - - total_visual_rows += segments.len(); - } - - if !cursor_found { - cursor_visual_row = total_visual_rows.saturating_sub(1); - cursor_col_width = 0; - } - - let mut scroll_top = 0usize; - if cursor_visual_row + 1 > visible_height { - scroll_top = cursor_visual_row + 1 - visible_height; - } - - let max_scroll = total_visual_rows.saturating_sub(visible_height); - if scroll_top > max_scroll { - scroll_top = max_scroll; - } - - let mut scroll_left = 0usize; - if !wrap_lines && content_width > 0 { - let max_scroll_left = cursor_line_total_width.saturating_sub(content_width); - if cursor_col_width + 1 > content_width { - scroll_left = cursor_col_width + 1 - content_width; - } - if scroll_left > max_scroll_left { - scroll_left = max_scroll_left; - } - } - - let visible_cursor_col = cursor_col_width.saturating_sub(scroll_left); - let cursor_visible_row = cursor_visual_row.saturating_sub(scroll_top); - let max_x = content_width.saturating_sub(1); - let cursor_y = inner.y + cursor_visible_row.min(visible_height.saturating_sub(1)) as u16; - let cursor_x = inner.x + visible_cursor_col.min(max_x) as u16; - - Some(CursorMetrics { - cursor_x, - cursor_y, - scroll_top: scroll_top as u16, - scroll_left: scroll_left.min(u16::MAX as usize) as u16, - }) -} - -fn wrap_line_segments(line: &str, width: usize) -> Vec { - if width == 0 { - return vec![String::new()]; - } - - if line.is_empty() { - return vec![String::new()]; - } - - // Manual wrapping that preserves all characters including spaces - let mut result = Vec::new(); - let mut current = String::new(); - let mut current_width = 0usize; - - for grapheme in line.graphemes(true) { - let grapheme_width = UnicodeWidthStr::width(grapheme); - - // If adding this character would exceed width, wrap to next line - if current_width + grapheme_width > width && !current.is_empty() { - result.push(current); - current = String::new(); - current_width = 0; - } - - // If even a single grapheme is too wide, add it as its own line - if grapheme_width > width { - result.push(grapheme.to_string()); - continue; - } - - current.push_str(grapheme); - current_width += grapheme_width; - } - - if !current.is_empty() { - result.push(current); - } - - if result.is_empty() { - result.push(String::new()); - } - - result -} - -fn apply_visual_selection<'a>( - lines: Vec>, - selection: Option<((usize, usize), (usize, usize))>, - theme: &owlen_core::Theme, -) -> Vec> { - if let Some(((start_row, start_col), (end_row, end_col))) = selection { - // Normalize selection (ensure start is before end) - let ((start_r, start_c), (end_r, end_c)) = - if start_row < end_row || (start_row == end_row && start_col <= end_col) { - ((start_row, start_col), (end_row, end_col)) - } else { - ((end_row, end_col), (start_row, start_col)) - }; - - lines - .into_iter() - .enumerate() - .map(|(idx, line)| { - if idx < start_r || idx > end_r { - // Line not in selection - return line; - } - - // Convert line to plain text for character indexing - let line_text = line.to_string(); - let char_count = line_text.chars().count(); - - if idx == start_r && idx == end_r { - // Selection within single line - let sel_start = start_c.min(char_count); - let sel_end = end_c.min(char_count); - - if sel_start >= sel_end { - return line; - } - - let start_byte = char_to_byte_index(&line_text, sel_start); - let end_byte = char_to_byte_index(&line_text, sel_end); - - let mut spans = Vec::new(); - if start_byte > 0 { - spans.push(Span::styled( - line_text[..start_byte].to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - spans.push(Span::styled( - line_text[start_byte..end_byte].to_string(), - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)), - )); - if end_byte < line_text.len() { - spans.push(Span::styled( - line_text[end_byte..].to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - Line::from(spans) - } else if idx == start_r { - // First line of multi-line selection - let sel_start = start_c.min(char_count); - let start_byte = char_to_byte_index(&line_text, sel_start); - - let mut spans = Vec::new(); - if start_byte > 0 { - spans.push(Span::styled( - line_text[..start_byte].to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - spans.push(Span::styled( - line_text[start_byte..].to_string(), - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)), - )); - Line::from(spans) - } else if idx == end_r { - // Last line of multi-line selection - let sel_end = end_c.min(char_count); - let end_byte = char_to_byte_index(&line_text, sel_end); - - let mut spans = Vec::new(); - spans.push(Span::styled( - line_text[..end_byte].to_string(), - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)), - )); - if end_byte < line_text.len() { - spans.push(Span::styled( - line_text[end_byte..].to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - Line::from(spans) - } else { - // Middle line - fully selected - let styled_spans: Vec = line - .spans - .into_iter() - .map(|span| { - Span::styled( - span.content, - span.style - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color( - &theme.selection_fg, - )), - ) - }) - .collect(); - Line::from(styled_spans) - } - }) - .collect() - } else { - lines - } -} - -fn char_to_byte_index(s: &str, char_idx: usize) -> usize { - if char_idx == 0 { - return 0; - } - - let mut iter = s.char_indices(); - for (i, (byte_idx, _)) in iter.by_ref().enumerate() { - if i == char_idx { - return byte_idx; - } - } - s.len() -} - -fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let theme = app.theme().clone(); - let has_focus = matches!(app.focused_panel(), FocusedPanel::Chat); - - // Calculate viewport dimensions for autoscroll calculations - let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders - let inner_width = usize::from(area.width.saturating_sub(2)).max(1); - let mut card_width = inner_width.saturating_sub(2); - if card_width > inner_width { - card_width = inner_width; - } - if card_width < MIN_MESSAGE_CARD_WIDTH { - card_width = inner_width.max(1); - } - card_width = card_width.clamp(1, inner_width); - let compact_cards = card_width < MIN_MESSAGE_CARD_WIDTH; - let body_width = if compact_cards { - card_width.saturating_sub(2).max(1) - } else { - card_width.saturating_sub(4).max(1) - }; - app.set_viewport_dimensions(viewport_height, body_width); - - let total_messages = app.message_count(); - let mut formatter = app.formatter(); - - // Reserve space for borders and the message indent so text fits within the block - formatter.set_wrap_width(body_width); - - // Build the lines for messages using cached rendering - let mut lines: Vec> = Vec::new(); - let role_label_mode = formatter.role_label_mode(); - for message_index in 0..total_messages { - let is_streaming = { - let conversation = app.conversation(); - conversation.messages[message_index] - .metadata - .get("streaming") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - }; - let message_lines = app.render_message_lines_cached( - message_index, - MessageRenderContext::new( - &mut formatter, - role_label_mode, - body_width, - card_width, - is_streaming, - app.get_loading_indicator(), - &theme, - app.should_highlight_code(), - app.render_markdown_enabled(), - ), - ); - lines.extend(message_lines); - } - - // Add loading indicator ONLY if we're loading and there are no messages at all, - // or if the last message is from the user (no Assistant response started yet) - let last_message_is_user = if total_messages == 0 { - true - } else { - let conversation = app.conversation(); - conversation - .messages - .last() - .map(|msg| matches!(msg.role, Role::User)) - .unwrap_or(true) - }; - - if app.get_loading_indicator() != "" && last_message_is_user { - match role_label_mode { - RoleLabelDisplay::Inline => { - let (emoji, title) = crate::chat_app::role_label_parts(&Role::Assistant); - let inline_label = format!("{emoji} {title}:"); - let label_width = UnicodeWidthStr::width(inline_label.as_str()); - let max_label_width = crate::chat_app::max_inline_label_width(); - let padding = max_label_width.saturating_sub(label_width); - - let mut loading_spans = vec![ - Span::raw(format!("{emoji} ")), - Span::styled( - format!("{title}:"), - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.assistant_message_role, - )) - .add_modifier(Modifier::BOLD), - ), - ]; - - if padding > 0 { - loading_spans.push(Span::raw(" ".repeat(padding))); - } - - loading_spans.push(Span::raw(" ")); - loading_spans.push(Span::styled( - app.get_loading_indicator().to_string(), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - )); - - lines.push(Line::from(loading_spans)); - } - _ => { - let loading_spans = vec![ - Span::raw("🤖 "), - Span::styled( - "Assistant:", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.assistant_message_role, - )) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {}", app.get_loading_indicator()), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - ), - ]; - lines.push(Line::from(loading_spans)); - } - } - } - - if lines.is_empty() { - lines.push(Line::from("No messages yet. Press 'i' to start typing.")); - } - - let scrollback_limit = app.scrollback_limit(); - if scrollback_limit != usize::MAX && lines.len() > scrollback_limit { - let removed = lines.len() - scrollback_limit; - lines = lines.into_iter().skip(removed).collect(); - app.apply_chat_scrollback_trim(removed, lines.len()); - } else { - app.apply_chat_scrollback_trim(0, lines.len()); - } - - // Apply visual selection highlighting if in visual mode and Chat panel is focused - if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat) - && let Some(selection) = app.visual_selection() - { - lines = apply_visual_selection(lines, Some(selection), &theme); - } - - // Update AutoScroll state with accurate content length - let auto_scroll = app.auto_scroll_mut(); - auto_scroll.content_len = lines.len(); - auto_scroll.on_viewport(viewport_height); - - let scroll_position = app.scroll().min(u16::MAX as usize) as u16; - - let mut title_spans = panel_title_spans("Chat", true, has_focus, &theme); - - let active_model = app.active_model_label(); - if !active_model.is_empty() { - let model_display = truncate_with_ellipsis(&active_model, 28); - title_spans.push(Span::raw(" · ")); - title_spans.push(Span::styled( - model_display, - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - )), - )); - } - - title_spans.push(Span::raw(" ")); - title_spans.push(Span::styled( - "PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus", - panel_hint_style(has_focus, &theme), - )); - - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); - let chat_block = Block::default() - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style( - Style::default() - .bg(if has_focus { - palette.active - } else { - palette.inactive - }) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .title(Line::from(title_spans)) - .title_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - ))); - - let paragraph = Paragraph::new(lines) - .style( - Style::default() - .bg(if has_focus { - palette.active - } else { - palette.inactive - }) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .block(chat_block) - .scroll((scroll_position, 0)); - - frame.render_widget(paragraph, area); - - if app.has_new_message_alert() { - let badge_text = "↓ New messages (press G)"; - let text_width = badge_text.chars().count() as u16; - let badge_width = text_width.saturating_add(2); - if area.width > badge_width + 1 && area.height > 2 { - let badge_x = area.x + area.width.saturating_sub(badge_width + 1); - let badge_y = area.y + 1; - let badge_area = Rect::new(badge_x, badge_y, badge_width, 1); - frame.render_widget(Clear, badge_area); - let badge_line = Line::from(Span::styled( - format!(" {badge_text} "), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .bg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )); - frame.render_widget( - Paragraph::new(badge_line) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.info)) - .fg(crate::color_convert::to_ratatui_color(&theme.background)), - ) - .alignment(Alignment::Center), - badge_area, - ); - } - } - - // Render cursor if Chat panel is focused and in Normal mode - if app.cursor_should_be_visible() - && matches!(app.focused_panel(), FocusedPanel::Chat) - && matches!(app.mode(), InputMode::Normal) - { - let cursor = app.chat_cursor(); - let cursor_row = cursor.0; - let cursor_col = cursor.1; - - // Calculate visible cursor position (accounting for scroll) - if cursor_row >= scroll_position as usize - && cursor_row < (scroll_position as usize + viewport_height) - { - let visible_row = cursor_row - scroll_position as usize; - let cursor_y = area.y + 1 + visible_row as u16; // +1 for border - - // Get the rendered line and calculate display width - let rendered_lines = app.get_rendered_lines(); - if let Some(line_text) = rendered_lines.get(cursor_row) { - let chars: Vec = line_text.chars().collect(); - let text_before_cursor: String = chars.iter().take(cursor_col).collect(); - let display_width = UnicodeWidthStr::width(text_before_cursor.as_str()); - - let cursor_x = area.x + 1 + display_width as u16; // +1 for border only - - frame.set_cursor_position((cursor_x, cursor_y)); - } - } - } -} - -fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let theme = app.theme().clone(); - - if let Some(thinking) = app.current_thinking().cloned() { - let reduced = app.is_reduced_chrome(); - let vertical_padding = if reduced { 0 } else { 2 }; - let horizontal_padding = if reduced { 2 } else { 4 }; - let viewport_height = area.height.saturating_sub(vertical_padding) as usize; - let content_width = area.width.saturating_sub(horizontal_padding); - - app.set_thinking_viewport_height(viewport_height); - - let chunks = crate::chat_app::wrap_unicode(&thinking, content_width as usize); - - let mut lines: Vec = chunks - .into_iter() - .map(|seg| { - Line::from(Span::styled( - seg, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - )) - }) - .collect(); - - // Apply visual selection highlighting if in visual mode and Thinking panel is focused - if matches!(app.mode(), InputMode::Visual) - && matches!(app.focused_panel(), FocusedPanel::Thinking) - && let Some(selection) = app.visual_selection() - { - lines = apply_visual_selection(lines, Some(selection), &theme); - } - - // Update AutoScroll state with accurate content length - let thinking_scroll = app.thinking_scroll_mut(); - thinking_scroll.content_len = lines.len(); - thinking_scroll.on_viewport(viewport_height); - - let scroll_position = app.thinking_scroll_position().min(u16::MAX as usize) as u16; - let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking); - let mut title_spans = panel_title_spans("💭 Thinking", true, has_focus, &theme); - title_spans.push(Span::raw(" ")); - title_spans.push(Span::styled( - "Esc close · Ctrl+4 focus", - panel_hint_style(has_focus, &theme), - )); - - let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); - let paragraph = Paragraph::new(lines) - .style( - Style::default() - .bg(if has_focus { - palette.active - } else { - palette.inactive - }) - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ) - .block( - Block::default() - .title(Line::from(title_spans)) - .title_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - ))) - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style( - Style::default() - .bg(if has_focus { - palette.active - } else { - palette.inactive - }) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ) - .scroll((scroll_position, 0)) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, area); - - // Render cursor if Thinking panel is focused and in Normal mode - if app.cursor_should_be_visible() && has_focus && matches!(app.mode(), InputMode::Normal) { - let cursor = app.thinking_cursor(); - let cursor_row = cursor.0; - let cursor_col = cursor.1; - - // Calculate visible cursor position (accounting for scroll) - if cursor_row >= scroll_position as usize - && cursor_row < (scroll_position as usize + viewport_height) - { - let visible_row = cursor_row - scroll_position as usize; - let cursor_y = area.y + 1 + visible_row as u16; // +1 for border - - // Calculate actual display width by measuring characters up to cursor - let line_text = thinking.lines().nth(cursor_row).unwrap_or(""); - let chars: Vec = line_text.chars().collect(); - let text_before_cursor: String = chars.iter().take(cursor_col).collect(); - let display_width = UnicodeWidthStr::width(text_before_cursor.as_str()); - - let cursor_x = area.x + 1 + display_width as u16; // +1 for border only - - frame.set_cursor_position((cursor_x, cursor_y)); - } - } - } -} - -fn render_attachment_preview(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let entries = app.attachment_preview_entries(); - if entries.is_empty() { - return; - } - - let theme = app.theme().clone(); - let reduced = app.is_reduced_chrome(); - let horizontal_padding = if reduced { 1 } else { 2 }; - let vertical_padding = if reduced { 0 } else { 1 }; - let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); - - let title = match app.attachment_preview_source() { - AttachmentPreviewSource::Pending => format!("Staged Attachments ({})", entries.len()), - AttachmentPreviewSource::Message(_) => { - format!("Message Attachments ({})", entries.len()) - } - AttachmentPreviewSource::None => format!("Attachments ({})", entries.len()), - }; - - let block = Block::default() - .borders(Borders::NONE) - .padding(Padding::new( - horizontal_padding, - horizontal_padding, - vertical_padding, - vertical_padding, - )) - .style( - Style::default() - .bg(palette.active) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .title(title) - .title_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - ))); - - let selected = app - .attachment_preview_selection() - .min(entries.len().saturating_sub(1)); - - let mut lines: Vec = Vec::new(); - for (idx, entry) in entries.iter().enumerate() { - let index_label = format!("{}. ", idx + 1); - let mut spans = Vec::new(); - spans.push(Span::styled( - index_label, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - )); - let style = if idx == selected { - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.assistant_message_role, - )) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)) - }; - spans.push(Span::styled(entry.summary.clone(), style)); - lines.push(Line::from(spans)); - } - - if let Some(entry) = entries.get(selected) - && !entry.preview_lines.is_empty() - { - lines.push(Line::from(vec![Span::styled( - "", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )])); - for preview in entry - .preview_lines - .iter() - .take(INLINE_ATTACHMENT_PREVIEW_LINES) - { - lines.push(Line::from(vec![Span::styled( - preview.clone(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )])); - } - } - - lines.push(Line::from(vec![Span::styled( - "Commands: :attachments next · :attachments prev · :attachments remove ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )])); - - if lines.is_empty() { - lines.push(Line::from(vec![Span::styled( - "No attachment details available", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )])); - } - - let paragraph = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .style( - Style::default() - .bg(palette.active) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ); - - frame.render_widget(paragraph, area); -} - -// Render a panel displaying the latest ReAct agent actions (thought/action/observation). -// Color-coded: THOUGHT (blue), ACTION (yellow), OBSERVATION (green) -fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let theme = app.theme().clone(); - let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking); - - if let Some(actions) = app.agent_actions().cloned() { - let reduced = app.is_reduced_chrome(); - let vertical_padding = if reduced { 0 } else { 2 }; - let horizontal_padding = if reduced { 2 } else { 4 }; - let viewport_height = area.height.saturating_sub(vertical_padding) as usize; - let content_width = area.width.saturating_sub(horizontal_padding); - - // Parse and color-code ReAct components - let mut lines: Vec = Vec::new(); - - for line in actions.lines() { - let line_trimmed = line.trim(); - - // Detect ReAct components and apply color coding - if line_trimmed.starts_with("THOUGHT:") { - let thought_color = theme.agent_thought; - let thought_content = line_trimmed.strip_prefix("THOUGHT:").unwrap_or("").trim(); - let wrapped = - crate::chat_app::wrap_unicode(thought_content, content_width as usize); - - // First line with label - if let Some(first) = wrapped.first() { - lines.push(Line::from(vec![ - Span::styled( - "THOUGHT: ", - Style::default() - .fg(to_ratatui_color(&thought_color)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - first.to_string(), - Style::default().fg(to_ratatui_color(&thought_color)), - ), - ])); - } - - // Continuation lines - for chunk in wrapped.iter().skip(1) { - lines.push(Line::from(Span::styled( - format!(" {}", chunk), - Style::default().fg(to_ratatui_color(&thought_color)), - ))); - } - } else if line_trimmed.starts_with("ACTION:") { - let action_color = theme.agent_action; - let action_content = line_trimmed.strip_prefix("ACTION:").unwrap_or("").trim(); - lines.push(Line::from(vec![ - Span::styled( - "ACTION: ", - Style::default() - .fg(to_ratatui_color(&action_color)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - action_content, - Style::default() - .fg(to_ratatui_color(&action_color)) - .add_modifier(Modifier::BOLD), - ), - ])); - } else if line_trimmed.starts_with("ACTION_INPUT:") { - let input_color = theme.agent_action_input; - let input_content = line_trimmed - .strip_prefix("ACTION_INPUT:") - .unwrap_or("") - .trim(); - let wrapped = crate::chat_app::wrap_unicode(input_content, content_width as usize); - - if let Some(first) = wrapped.first() { - lines.push(Line::from(vec![ - Span::styled( - "ACTION_INPUT: ", - Style::default() - .fg(to_ratatui_color(&input_color)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - first.to_string(), - Style::default().fg(to_ratatui_color(&input_color)), - ), - ])); - } - - for chunk in wrapped.iter().skip(1) { - lines.push(Line::from(Span::styled( - format!(" {}", chunk), - Style::default().fg(to_ratatui_color(&input_color)), - ))); - } - } else if line_trimmed.starts_with("OBSERVATION:") { - let observation_color = theme.agent_observation; - let obs_content = line_trimmed - .strip_prefix("OBSERVATION:") - .unwrap_or("") - .trim(); - let wrapped = crate::chat_app::wrap_unicode(obs_content, content_width as usize); - - if let Some(first) = wrapped.first() { - lines.push(Line::from(vec![ - Span::styled( - "OBSERVATION: ", - Style::default() - .fg(to_ratatui_color(&observation_color)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - first.to_string(), - Style::default().fg(to_ratatui_color(&observation_color)), - ), - ])); - } - - for chunk in wrapped.iter().skip(1) { - lines.push(Line::from(Span::styled( - format!(" {}", chunk), - Style::default().fg(to_ratatui_color(&observation_color)), - ))); - } - } else if line_trimmed.starts_with("FINAL_ANSWER:") { - let answer_color = theme.agent_final_answer; - let answer_content = line_trimmed - .strip_prefix("FINAL_ANSWER:") - .unwrap_or("") - .trim(); - let wrapped = crate::chat_app::wrap_unicode(answer_content, content_width as usize); - - if let Some(first) = wrapped.first() { - lines.push(Line::from(vec![ - Span::styled( - "FINAL_ANSWER: ", - Style::default() - .fg(to_ratatui_color(&answer_color)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - first.to_string(), - Style::default() - .fg(to_ratatui_color(&answer_color)) - .add_modifier(Modifier::BOLD), - ), - ])); - } - - for chunk in wrapped.iter().skip(1) { - lines.push(Line::from(Span::styled( - format!(" {}", chunk), - Style::default().fg(to_ratatui_color(&answer_color)), - ))); - } - } else if !line_trimmed.is_empty() { - // Regular text - let wrapped = crate::chat_app::wrap_unicode(line_trimmed, content_width as usize); - for chunk in wrapped { - lines.push(Line::from(Span::styled( - chunk, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - ))); - } - } else { - // Empty line - lines.push(Line::from("")); - } - } - - let mut title_spans = panel_title_spans("🤖 Agent Actions", true, has_focus, &theme); - title_spans.push(Span::raw(" ")); - title_spans.push(Span::styled( - "Pause ▸ p · Resume ▸ r · Ctrl+4 focus", - panel_hint_style(has_focus, &theme), - )); - - let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); - let paragraph = Paragraph::new(lines) - .style( - Style::default() - .bg(if has_focus { - palette.active - } else { - palette.inactive - }) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .block( - Block::default() - .title(Line::from(title_spans)) - .title_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - ))) - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style( - Style::default() - .bg(if has_focus { - palette.active - } else { - palette.inactive - }) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, area); - _ = viewport_height; - } -} - -fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let theme = app.theme().clone(); - let has_focus = matches!(app.focused_panel(), FocusedPanel::Input); - let (label, hint) = match app.mode() { - InputMode::Editing => ( - "Input", - Some("Enter send · Shift+Enter newline · Esc normal · Ctrl+5 focus"), - ), - InputMode::Visual => ( - "Visual Select", - Some("y yank · d cut · Esc cancel · Ctrl+5 focus"), - ), - InputMode::Command => ("Command", Some("Enter run · Esc cancel · Ctrl+5 focus")), - InputMode::RepoSearch => ( - "Repo Search", - Some("Enter run · Alt+Enter scratch · Esc close · Ctrl+5 focus"), - ), - InputMode::SymbolSearch => ( - "Symbol Search", - Some("Type @name · Esc close · Ctrl+5 focus"), - ), - _ => ("Input", Some("Press i to start typing · Ctrl+5 focus")), - }; - - let is_active = matches!( - app.mode(), - InputMode::Editing - | InputMode::Visual - | InputMode::Command - | InputMode::RepoSearch - | InputMode::SymbolSearch - ); - - let mut title_spans = panel_title_spans(label, is_active, has_focus, &theme); - if let Some(hint_text) = hint { - title_spans.push(Span::raw(" ")); - title_spans.push(Span::styled( - hint_text.to_string(), - panel_hint_style(has_focus, &theme), - )); - } - - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); - let base_style = Style::default() - .bg(if has_focus { - palette.active - } else { - palette.inactive - }) - .fg(crate::color_convert::to_ratatui_color(&theme.text)); - - let input_block = Block::default() - .title(Line::from(title_spans)) - .title_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - ))) - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style(base_style); - - if matches!(app.mode(), InputMode::Editing) { - // Use the textarea directly to preserve selection state - let show_cursor = app.cursor_should_be_visible(); - let textarea = app.textarea_mut(); - textarea.set_block(input_block.clone()); - textarea.set_hard_tab_indent(false); - textarea.set_style(base_style); - render_editable_textarea(frame, area, textarea, true, show_cursor, &theme); - } else if matches!(app.mode(), InputMode::Visual) { - // In visual mode, render textarea in read-only mode with selection - let show_cursor = app.cursor_should_be_visible(); - let textarea = app.textarea_mut(); - textarea.set_block(input_block.clone()); - textarea.set_hard_tab_indent(false); - textarea.set_style(base_style); - render_editable_textarea(frame, area, textarea, true, show_cursor, &theme); - } else if matches!(app.mode(), InputMode::Command) { - // In command mode, show the command buffer with : prefix - let command_text = format!(":{}", app.command_buffer()); - let lines = vec![Line::from(Span::styled( - command_text, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.mode_command)) - .add_modifier(Modifier::BOLD), - ))]; - - let paragraph = Paragraph::new(lines) - .style(base_style) - .block(input_block) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, area); - } else { - // In non-editing mode, show the current input buffer content as read-only - let input_text = app.input_buffer_text(); - let lines: Vec = if input_text.is_empty() { - vec![Line::from(Span::styled( - "Press 'i' to start typing", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ))] - } else { - input_text - .lines() - .map(|l| { - Line::from(Span::styled( - l, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - )) - }) - .collect() - }; - - let paragraph = Paragraph::new(lines) - .style(base_style) - .block(input_block) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, area); - } -} - -fn system_status_message(app: &ChatApp) -> String { - let system_status = app.system_status(); - - if !system_status.is_empty() { - system_status.to_string() - } else if let Some(error) = app.error_message() { - format!("Error: {error}") - } else { - let status = app.status_message(); - if status.is_empty() || status == "Ready" { - "Ready".to_string() - } else { - status.to_string() - } - } -} - -fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, message: &str) { - let theme = app.theme(); - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); - - let color = if message.starts_with("Error:") { - theme.error - } else { - theme.info - }; - - let text_lines: Vec = if message.is_empty() { - vec![Line::from(Span::styled( - "Ready", - Style::default().fg(to_ratatui_color(&color)), - ))] - } else { - message - .lines() - .map(|line| { - Line::from(Span::styled( - line.to_string(), - Style::default().fg(to_ratatui_color(&color)), - )) - }) - .collect() - }; - - let paragraph = Paragraph::new(text_lines) - .style( - Style::default() - .bg(palette.highlight) - .fg(to_ratatui_color(&color)), - ) - .block( - Block::default() - .title(Span::styled( - " System/Status ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )) - .title_style( - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - ) - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style( - Style::default() - .bg(palette.highlight) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, area); -} - -fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { - let theme = app.theme(); - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); - frame.render_widget(Clear, area); - - let title = Line::from(vec![ - Span::styled( - " Debug log ", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - )) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "warnings & errors", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text, - )) - .add_modifier(Modifier::DIM), - ), - ]); - - let block = Block::default() - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style( - Style::default() - .bg(palette.active) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .title(title) - .title_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - ))); - - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.width == 0 || inner.height == 0 { - return; - } - - let entries = app.debug_log_entries(); - let available_rows = inner.height as usize; - let mut lines: Vec = Vec::new(); - - if entries.is_empty() { - lines.push(Line::styled( - "No warnings captured this session.", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text, - )) - .add_modifier(Modifier::DIM), - )); - } else { - let total_entries = entries.len(); - let mut subset: Vec<_> = entries.into_iter().rev().take(available_rows).collect(); - subset.reverse(); - - if total_entries > subset.len() && subset.len() == available_rows && !subset.is_empty() { - subset.remove(0); - } - - let overflow = total_entries.saturating_sub(subset.len()); - if overflow > 0 { - lines.push(Line::styled( - format!("… {overflow} older entries not shown"), - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text, - )) - .add_modifier(Modifier::DIM), - )); - } - - for entry in subset { - let (label, badge_style, message_style) = debug_level_styles(entry.level, theme); - let timestamp = entry.timestamp.format("%H:%M:%S"); - - let mut spans = vec![ - Span::styled(format!(" {label} "), badge_style), - Span::raw(" "), - Span::styled( - timestamp.to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_hint_text, - )) - .add_modifier(Modifier::DIM), - ), - ]; - - if !entry.target.is_empty() { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - entry.target, - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - )), - )); - } - - spans.push(Span::raw(" ")); - spans.push(Span::styled(entry.message, message_style)); - lines.push(Line::from(spans)); - } - } - - let paragraph = Paragraph::new(lines) - .wrap(Wrap { trim: true }) - .alignment(Alignment::Left) - .style( - Style::default() - .bg(palette.active) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ); - - frame.render_widget(paragraph, inner); -} - -fn debug_level_styles(level: Level, theme: &Theme) -> (&'static str, Style, Style) { - match level { - Level::Error => ( - "ERR", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .bg(crate::color_convert::to_ratatui_color(&theme.error)) - .add_modifier(Modifier::BOLD), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.error)), - ), - Level::Warn => ( - "WARN", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .bg(crate::color_convert::to_ratatui_color(&theme.agent_action)) - .add_modifier(Modifier::BOLD), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.agent_action)), - ), - _ => ( - "INFO", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .bg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - } -} - -fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize -where - I: IntoIterator, -{ - let content_width = available_width.saturating_sub(4) as usize; // account for padded panel chrome - - let mut total = 0usize; - let mut seen = false; - for line in lines.into_iter() { - seen = true; - if content_width == 0 || line.is_empty() { - total += 1; - continue; - } - total += wrap_line_segments(line, content_width).len().max(1); - } - - if !seen { 1 } else { total.max(1) } -} - -fn render_status(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let theme = app.theme().clone(); - let layer_settings = app.layer_settings().clone(); - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(&theme, reduced, &layer_settings); - - frame.render_widget(Clear, area); - - let block = Block::default() - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 0, 0) - }) - .style(Style::default().bg(palette.highlight)); - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.height == 0 || inner.width == 0 { - return; - } - - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(30), - Constraint::Min(36), - Constraint::Length(42), - ]) - .split(inner); - - if columns.len() < 3 { - render_status_center(frame, inner, app, &palette, &theme); - return; - } - - render_status_center(frame, columns[1], app, &palette, &theme); - - let app_ref: &ChatApp = app; - render_status_left(frame, columns[0], app_ref, &palette, &theme); - render_status_right(frame, columns[2], app_ref, &palette, &theme); -} - -fn render_status_left( - frame: &mut Frame<'_>, - area: Rect, - app: &ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let (mode_label, mode_color) = match app.mode() { - InputMode::Normal => ("NORMAL", theme.mode_normal), - InputMode::Editing => ("INSERT", theme.mode_editing), - InputMode::ModelSelection => ("MODEL", theme.mode_model_selection), - InputMode::ProviderSelection => ("PROVIDER", theme.mode_provider_selection), - InputMode::Help => ("HELP", theme.mode_help), - InputMode::Visual => ("VISUAL", theme.mode_visual), - InputMode::Command => ("COMMAND", theme.mode_command), - InputMode::SessionBrowser => ("SESSIONS", theme.mode_command), - InputMode::ThemeBrowser => ("THEMES", theme.mode_help), - InputMode::RepoSearch => ("SEARCH", theme.mode_command), - InputMode::SymbolSearch => ("SYMBOLS", theme.mode_command), - }; - - let mode_badge_style = if app.mode_flash_active() { - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .bg(to_ratatui_color(&mode_color)) - .fg(crate::color_convert::to_ratatui_color(&theme.background)) - .add_modifier(Modifier::BOLD) - }; - - let (op_label, op_fg, op_bg) = match app.get_mode() { - owlen_core::mode::Mode::Chat => ("CHAT", theme.operating_chat_fg, theme.operating_chat_bg), - owlen_core::mode::Mode::Code => ("CODE", theme.operating_code_fg, theme.operating_code_bg), - }; - - let (focus_label, focus_hint) = match app.focused_panel() { - FocusedPanel::Files => ("FILES", "Ctrl+1"), - FocusedPanel::Chat => ("CHAT", "Ctrl+2"), - FocusedPanel::Thinking => ("THINK", "Ctrl+4"), - FocusedPanel::Input => ("INPUT", "Ctrl+5"), - FocusedPanel::Code => ("CODE", "Ctrl+3"), - }; - - let mut lines = Vec::new(); - lines.push(Line::from(vec![ - Span::styled(format!(" {} ", mode_label), mode_badge_style), - Span::raw(" "), - Span::styled( - format!(" {} ", op_label), - Style::default() - .bg(to_ratatui_color(&op_bg)) - .fg(to_ratatui_color(&op_fg)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - format!("Focus {focus_label} · {focus_hint}"), - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - )) - .add_modifier(Modifier::BOLD | Modifier::ITALIC), - ), - ])); - - if app.is_agent_running() { - lines.push(Line::from(vec![Span::styled( - "Agent running · Esc stops", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_running_bg, - )) - .add_modifier(Modifier::BOLD), - )])); - } else if app.is_agent_mode() { - lines.push(Line::from(vec![Span::styled( - "Agent armed · Alt+A toggle", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_idle_bg, - )) - .add_modifier(Modifier::BOLD), - )])); - } - - if app.has_new_message_alert() { - lines.push(Line::from(vec![Span::styled( - "New replies waiting · End to catch up", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.agent_action)) - .add_modifier(Modifier::BOLD), - )])); - } - - lines.push(Line::from(vec![ - Span::styled( - "F1", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " Help ", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ), - Span::styled( - "?", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " Guidance ", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ), - Span::styled( - "F12", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " Debug log", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ), - ])); - - frame.render_widget( - Paragraph::new(lines) - .alignment(Alignment::Left) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn render_status_center( - frame: &mut Frame<'_>, - area: Rect, - app: &mut ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let (level, primary, detail, auxiliary) = compute_status_message(app); - let (badge_style, _) = status_level_colors(level, theme); - let icon = status_level_icon(level); - - let mut constraints = vec![Constraint::Length(2)]; - if detail.is_some() { - constraints.push(Constraint::Length(1)); - } - if auxiliary.is_some() { - constraints.push(Constraint::Length(1)); - } - constraints.push(Constraint::Length(3)); - constraints.push(Constraint::Min(0)); - - let regions = Layout::vertical(constraints).split(area); - let mut index = 0; - - let primary_line = Line::from(vec![ - Span::styled(format!(" {} ", icon), badge_style), - Span::raw(" "), - Span::styled( - primary, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)) - .add_modifier(Modifier::BOLD), - ), - ]); - frame.render_widget( - Paragraph::new(primary_line) - .alignment(Alignment::Left) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - regions[index], - ); - index += 1; - - if let Some(detail_text) = detail { - frame.render_widget( - Paragraph::new(detail_text) - .style( - Style::default() - .bg(palette.highlight) - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ) - .wrap(Wrap { trim: true }), - regions[index], - ); - index += 1; - } - - if let Some(aux_text) = auxiliary { - frame.render_widget( - Paragraph::new(aux_text) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - regions[index], - ); - index += 1; - } - - if index < regions.len() { - render_status_progress(frame, regions[index], app, palette, theme); - } -} - -fn render_status_right( - frame: &mut Frame<'_>, - area: Rect, - app: &ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let file_tree = app.file_tree(); - let repo_label = if let Some(branch) = file_tree.git_branch() { - format!("{}@{}", branch, file_tree.repo_name()) - } else { - file_tree.repo_name().to_string() - }; - - let current_path = if let Some(path) = app.code_view_path() { - Some(path.to_string()) - } else if let Some(node) = file_tree.selected_node() { - if node.path.as_os_str().is_empty() { - None - } else { - Some(node.path.to_string_lossy().into_owned()) - } - } else { - None - }; - - let position_label = status_cursor_position(app); - let language_label = language_label_for_path(current_path.as_deref()); - let encoding_label = "UTF-8"; - - let provider = app.current_provider(); - let provider_display = truncate_with_ellipsis(provider, 16); - let model_label = app.active_model_label(); - let model_display = truncate_with_ellipsis(&model_label, 24); - - let mut lines = Vec::new(); - lines.push(Line::from(vec![Span::styled( - repo_label, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::BOLD), - )])); - - if let Some(path) = current_path.as_ref() { - lines.push(Line::from(vec![Span::styled( - truncate_with_ellipsis(path, area.width.saturating_sub(4) as usize), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - )])); - } - - lines.push(Line::from(vec![Span::styled( - format!( - "{} · {} · {}", - position_label, language_label, encoding_label - ), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )])); - - lines.push(Line::from("")); - let mut provider_spans = vec![Span::styled( - format!("{} ▸ {}", provider_display, model_display), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - )]; - if app.is_streaming() { - let spinner = app.get_loading_indicator(); - let spinner = if spinner.is_empty() { "…" } else { spinner }; - provider_spans.push(Span::styled( - format!(" · {} streaming", spinner), - Style::default().fg(to_ratatui_color(&toast_level_color( - ToastLevel::Info, - theme, - ))), - )); - } - provider_spans.push(Span::styled( - " · LSP:✓", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )); - lines.push(Line::from(provider_spans)); - - if app.is_loading() && !app.is_streaming() { - let spinner = app.get_loading_indicator(); - if !spinner.is_empty() { - lines.push(Line::from(vec![Span::styled( - format!("Loading {spinner} · Esc cancels"), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Toast history", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.pane_header_active, - )) - .add_modifier(Modifier::BOLD), - )])); - - let history: Vec<_> = app.toast_history().take(3).cloned().collect(); - let message_width = area.width.saturating_sub(10).max(12) as usize; - - if history.is_empty() { - lines.push(Line::from(vec![Span::styled( - "No recent toasts", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - )])); - } else { - for entry in history { - let color = toast_level_color(entry.level, theme); - let icon = toast_icon(entry.level); - let age = format_elapsed_short(entry.recorded().elapsed()); - let message = truncate_to_width(&entry.message, message_width); - let mut spans = vec![ - Span::styled( - format!("{icon} "), - Style::default().fg(to_ratatui_color(&color)), - ), - Span::styled( - message, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - Span::raw(" "), - Span::styled( - age, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - ), - ]; - if let Some(hint) = entry.shortcut_hint.clone() { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - hint, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - )); - } - lines.push(Line::from(spans)); - } - } - - frame.render_widget( - Paragraph::new(lines) - .alignment(Alignment::Left) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn compute_status_message(app: &ChatApp) -> (StatusLevel, String, Option, Option) { - let status_text = app.status.trim(); - let system_text = app.system_status().trim(); - - if let Some(error) = app.error.as_ref().filter(|err| !err.trim().is_empty()) { - let detail = if status_text.is_empty() { - None - } else { - Some(status_text.to_string()) - }; - let aux = if system_text.is_empty() { - None - } else { - Some(system_text.to_string()) - }; - return (StatusLevel::Error, error.trim().to_string(), detail, aux); - } - - let mut level = StatusLevel::Info; - let primary = if status_text.is_empty() { - default_status_message(app) - } else { - let text = status_text.to_string(); - let lower = text.to_lowercase(); - if lower.contains("success") || lower.contains("saved") || lower.contains("ready") { - level = StatusLevel::Success; - } else if lower.contains("warn") || lower.contains("slow") || lower.contains("limit") { - level = StatusLevel::Warning; - } - text - }; - - let detail = if system_text.is_empty() { - None - } else { - Some(system_text.to_string()) - }; - - (level, primary, detail, None) -} - -fn default_status_message(app: &ChatApp) -> String { - let mode = app.mode().to_string(); - format!("Ready · {} mode", mode) -} - -fn render_status_progress( - frame: &mut Frame<'_>, - area: Rect, - app: &mut ChatApp, - palette: &GlassPalette, - theme: &Theme, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let bar_width = area.width.saturating_sub(18).max(8) as usize; - let mut lines: Vec = Vec::new(); - - if let Some(descriptor) = app - .context_usage_with_fallback() - .and_then(context_usage_descriptor) - { - let ratio = app.animated_gauge_ratio(GaugeKey::Context, descriptor.ratio); - let bar = unicode_progress_bar(bar_width, ratio); - lines.push(Line::from(vec![ - Span::styled( - "Context ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - bar, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - ), - Span::raw(format!( - " {} ({})", - descriptor.percent_label, descriptor.detail - )), - ])); - } else { - app.reset_gauge(GaugeKey::Context); - } - - if let Some(snapshot) = app.usage_snapshot().cloned() { - if let Some(descriptor) = usage_gauge_descriptor(&snapshot, UsageWindow::Hour) { - let ratio = app.animated_gauge_ratio(GaugeKey::UsageHour, descriptor.ratio); - let bar = unicode_progress_bar(bar_width, ratio); - lines.push(Line::from(vec![ - Span::styled( - "Cloud hr ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.agent_action)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - bar, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.agent_action)), - ), - Span::raw(format!(" {}", descriptor.detail)), - ])); - } - if let Some(descriptor) = usage_gauge_descriptor(&snapshot, UsageWindow::Week) { - let ratio = app.animated_gauge_ratio(GaugeKey::UsageWeek, descriptor.ratio); - let bar = unicode_progress_bar(bar_width, ratio); - lines.push(Line::from(vec![ - Span::styled( - "Cloud wk ", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_idle_bg, - )) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - bar, - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.agent_badge_idle_bg, - )), - ), - Span::raw(format!(" {}", descriptor.detail)), - ])); - } - } else { - app.reset_gauge(GaugeKey::UsageHour); - app.reset_gauge(GaugeKey::UsageWeek); - } - - if app.is_streaming() { - let spinner = app.get_loading_indicator(); - let spinner = if spinner.is_empty() { "…" } else { spinner }; - lines.push(Line::from(vec![ - Span::styled( - format!("Streaming {spinner} "), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - ), - Span::styled( - "p Pause · r Resume · s Stop", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - ), - ])); - } else if app.is_loading() { - let spinner = app.get_loading_indicator(); - if !spinner.is_empty() { - lines.push(Line::from(vec![ - Span::styled( - format!("Loading {spinner} "), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - ), - Span::styled( - "Esc cancels", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - ), - ])); - } - } - - if lines.is_empty() { - lines.push(Line::from(vec![Span::styled( - "Usage metrics pending · run :limits", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - )])); - } - - frame.render_widget( - Paragraph::new(lines) - .alignment(Alignment::Left) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn unicode_progress_bar(width: usize, ratio: f64) -> String { - if width == 0 { - return String::new(); - } - let width = width.clamp(1, 40); - let clamped = ratio.clamp(0.0, 1.0); - let filled = (clamped * width as f64).round() as usize; - let mut bar = String::with_capacity(width); - for index in 0..width { - if index < filled { - bar.push('█'); - } else { - bar.push('░'); - } - } - bar -} - -fn format_elapsed_short(duration: Duration) -> String { - let secs = duration.as_secs(); - if secs == 0 { - "<1s".to_string() - } else if secs < 60 { - format!("{}s", secs) - } else if secs < 3600 { - format!("{}m", secs / 60) - } else { - format!("{}h", secs / 3600) - } -} - -fn toast_level_color(level: ToastLevel, theme: &Theme) -> owlen_core::Color { - match level { - ToastLevel::Info => theme.info, - ToastLevel::Success => theme.agent_badge_idle_bg, - ToastLevel::Warning => theme.agent_action, - ToastLevel::Error => theme.error, - } -} - -fn toast_icon(level: ToastLevel) -> &'static str { - match level { - ToastLevel::Info => "ⓘ", - ToastLevel::Success => "✔", - ToastLevel::Warning => "⚠", - ToastLevel::Error => "✖", - } -} - -pub(crate) fn format_token_short(value: u64) -> String { - if value >= 1_000_000_000 { - format_compact(value as f64 / 1_000_000_000.0, "B") - } else if value >= 1_000_000 { - format_compact(value as f64 / 1_000_000.0, "M") - } else if value >= 1_000 { - format_compact(value as f64 / 1_000.0, "k") - } else { - value.to_string() - } -} - -fn format_compact(value: f64, suffix: &str) -> String { - let formatted = if value >= 100.0 { - format!("{:.0}", value) - } else { - format!("{:.1}", value) - }; - - let trimmed = formatted.trim_end_matches('0').trim_end_matches('.'); - let mut result = trimmed.to_string(); - if result.is_empty() { - result.push('0'); - } - result.push_str(suffix); - result -} - -fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { - if max_width == 0 { - return String::new(); - } - - let current = UnicodeWidthStr::width(text); - if current <= max_width { - return text.to_string(); - } - - let ellipsis = "…"; - let ellipsis_width = UnicodeWidthStr::width(ellipsis); - if ellipsis_width >= max_width { - return ellipsis.to_string(); - } - - let keep_width = max_width - ellipsis_width; - let prefix = truncate_to_width(text, keep_width); - if prefix.is_empty() { - ellipsis.to_string() - } else { - format!("{}{}", prefix, ellipsis) - } -} - -fn spans_within_width(spans: Vec>, max_width: u16) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let mut remaining = max_width as usize; - let mut output = Vec::new(); - - for span in spans.into_iter() { - if remaining == 0 { - break; - } - - let text = span.content.as_ref(); - let width = UnicodeWidthStr::width(text); - if width == 0 { - continue; - } - - let style = span.style; - if width <= remaining { - output.push(Span::styled(text.to_string(), style)); - remaining -= width; - } else { - let truncated = truncate_to_width(text, remaining); - if !truncated.is_empty() { - output.push(Span::styled(truncated, style)); - } - break; - } - } - - Line::from(output) -} - -fn truncate_to_width(text: &str, max_width: usize) -> String { - if max_width == 0 { - return String::new(); - } - - let mut result = String::new(); - let mut used = 0; - for grapheme in text.graphemes(true) { - let width = UnicodeWidthStr::width(grapheme); - if used + width > max_width { - break; - } - result.push_str(grapheme); - used += width; - } - result -} - -fn render_code_workspace(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let theme = app.theme().clone(); - frame.render_widget(Clear, area); - - if area.width == 0 || area.height == 0 { - return; - } - - if app.workspace().tabs().is_empty() { - render_empty_workspace(frame, area, &theme); - return; - } - - let (repo_name, repo_root) = { - let tree = app.file_tree(); - (tree.repo_name().to_string(), tree.root().to_path_buf()) - }; - - let show_tab_bar = area.height > 2; - let content_area = if show_tab_bar { - let segments = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(2), Constraint::Min(1)]) - .split(area); - render_code_tab_bar(frame, segments[0], app, &theme); - segments[1] - } else { - area - }; - - render_code_tab_content( - frame, - content_area, - app, - &theme, - &repo_name, - repo_root.as_path(), - ); -} - -fn render_code_tab_bar(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, theme: &Theme) { - if area.width == 0 || area.height == 0 { - return; - } - - let tabs = app.workspace().tabs(); - if tabs.is_empty() { - return; - } - - let active_index = app.workspace().active_tab_index(); - let mut spans: Vec> = Vec::new(); - - for (index, tab) in tabs.iter().enumerate() { - if index > 0 { - spans.push(Span::styled( - " ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )); - } - - let dirty_marker = tab - .panes - .get(&tab.active) - .map(|pane| pane.is_dirty) - .unwrap_or(false); - let dirty_char = if dirty_marker { "●" } else { " " }; - let label = format!(" {} {} {} ", index + 1, dirty_char, tab.title); - - let style = if index == active_index { - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM) - }; - - spans.push(Span::styled(label, style)); - } - - let line = Line::from(spans); - let paragraph = Paragraph::new(line) - .alignment(Alignment::Left) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color( - &theme.status_background, - )) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .block( - Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - ))), - ); - - frame.render_widget(paragraph, area); -} - -fn render_code_tab_content( - frame: &mut Frame<'_>, - area: Rect, - app: &mut ChatApp, - theme: &Theme, - repo_name: &str, - repo_root: &Path, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let has_focus = matches!(app.focused_panel(), FocusedPanel::Code); - let active_index = app.workspace().active_tab_index(); - - let workspace = app.workspace_mut(); - let tabs = workspace.tabs_mut(); - if let Some(tab) = tabs.get_mut(active_index) { - let EditorTab { - root, - panes, - active, - .. - } = tab; - if panes.is_empty() { - render_empty_workspace(frame, area, theme); - return; - } - let active_pane = *active; - render_workspace_node( - frame, - area, - root, - panes, - active_pane, - theme, - has_focus, - repo_name, - repo_root, - ); - } else { - render_empty_workspace(frame, area, theme); - } -} - -#[allow(clippy::too_many_arguments)] -fn render_workspace_node( - frame: &mut Frame<'_>, - area: Rect, - node: &mut LayoutNode, - panes: &mut HashMap, - active_pane: PaneId, - theme: &Theme, - has_focus: bool, - repo_name: &str, - repo_root: &Path, -) { - if area.width == 0 || area.height == 0 { - return; - } - - match node { - LayoutNode::Leaf(id) => { - if let Some(pane) = panes.get_mut(id) { - let is_active = *id == active_pane; - render_code_pane( - frame, area, pane, theme, has_focus, is_active, repo_name, repo_root, - ); - } else { - render_empty_workspace(frame, area, theme); - } - } - LayoutNode::Split { - axis, - ratio, - first, - second, - } => { - let (first_area, second_area) = split_rect(area, *axis, *ratio); - if first_area.width > 0 && first_area.height > 0 { - render_workspace_node( - frame, - first_area, - first.as_mut(), - panes, - active_pane, - theme, - has_focus, - repo_name, - repo_root, - ); - } - if second_area.width > 0 && second_area.height > 0 { - render_workspace_node( - frame, - second_area, - second.as_mut(), - panes, - active_pane, - theme, - has_focus, - repo_name, - repo_root, - ); - } - } - } -} - -#[allow(clippy::too_many_arguments)] -fn render_code_pane( - frame: &mut Frame<'_>, - area: Rect, - pane: &mut CodePane, - theme: &Theme, - has_focus: bool, - is_active: bool, - repo_name: &str, - repo_root: &Path, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let viewport_height = area.height.saturating_sub(2) as usize; - pane.set_viewport_height(viewport_height); - - let mut lines: Vec = Vec::new(); - if pane.lines.is_empty() { - lines.push(Line::from(Span::styled( - "(empty file)", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - ))); - } else { - let mut highlighter = - highlight::build_highlighter(pane.absolute_path(), pane.display_path()); - for (idx, content) in pane.lines.iter().enumerate() { - let number = format!("{:>4} ", idx + 1); - let mut spans = vec![Span::styled( - number, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )]; - - let segments = highlight::highlight_line(&mut highlighter, content); - if segments.is_empty() { - let mut line_style = - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)); - if !is_active { - line_style = line_style.add_modifier(Modifier::DIM); - } - spans.push(Span::styled(content.clone(), line_style)); - } else { - for (segment_style, text) in segments { - let mut style = segment_style; - if !is_active { - style = style.add_modifier(Modifier::DIM); - } - spans.push(Span::styled(text, style)); - } - } - lines.push(Line::from(spans)); - } - } - - pane.scroll.content_len = lines.len(); - pane.scroll.on_viewport(viewport_height); - let scroll_position = pane.scroll.scroll.min(u16::MAX as usize) as u16; - - let fallback_title = pane - .display_path() - .map(|s| s.to_string()) - .or_else(|| { - pane.absolute_path() - .map(|p| p.to_string_lossy().into_owned()) - }) - .unwrap_or_else(|| pane.title.clone()); - - let breadcrumb = pane - .absolute_path() - .and_then(|abs| { - diff_paths(abs, repo_root) - .or_else(|| abs.strip_prefix(repo_root).ok().map(PathBuf::from)) - .map(|rel| build_breadcrumbs(repo_name, rel.as_path())) - }) - .or_else(|| { - pane.display_path() - .map(|display| build_breadcrumbs(repo_name, Path::new(display))) - }); - - let header_label = breadcrumb.unwrap_or_else(|| fallback_title.clone()); - - let mut title_spans = panel_title_spans(header_label, is_active, has_focus && is_active, theme); - if is_active { - title_spans.push(Span::raw(" ")); - title_spans.push(Span::styled( - "Ctrl+W split · :w save · Ctrl+3 focus", - panel_hint_style(has_focus && is_active, theme), - )); - } - - let paragraph = Paragraph::new(lines) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(panel_border_style(is_active, has_focus && is_active, theme)) - .title(Line::from(title_spans)), - ) - .scroll((scroll_position, 0)) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, area); -} - -fn render_empty_workspace(frame: &mut Frame<'_>, area: Rect, theme: &Theme) { - if area.width == 0 || area.height == 0 { - return; - } - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - ))) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ) - .title(Span::styled( - "No file open", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - )); - - let paragraph = Paragraph::new(Line::from(Span::styled( - "Open a file from the tree or palette", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - ))) - .alignment(Alignment::Center) - .block(block); - - frame.render_widget(paragraph, area); -} - -fn split_rect(area: Rect, axis: SplitAxis, ratio: f32) -> (Rect, Rect) { - if area.width == 0 || area.height == 0 { - return (area, Rect::new(area.x, area.y, 0, 0)); - } - - let ratio = ratio.clamp(0.1, 0.9); - match axis { - SplitAxis::Horizontal => { - let (first, _) = split_lengths(area.height, ratio); - let first_rect = Rect::new(area.x, area.y, area.width, first); - let second_rect = Rect::new( - area.x, - area.y.saturating_add(first), - area.width, - area.height.saturating_sub(first), - ); - (first_rect, second_rect) - } - SplitAxis::Vertical => { - let (first, _) = split_lengths(area.width, ratio); - let first_rect = Rect::new(area.x, area.y, first, area.height); - let second_rect = Rect::new( - area.x.saturating_add(first), - area.y, - area.width.saturating_sub(first), - area.height, - ); - (first_rect, second_rect) - } - } -} - -fn split_lengths(total: u16, ratio: f32) -> (u16, u16) { - if total <= 1 { - return (total, 0); - } - let mut first = ((total as f32) * ratio).round() as u16; - first = first.max(1).min(total - 1); - let second = total - first; - (first, second) -} - -fn status_cursor_position(app: &ChatApp) -> String { - let (line, col) = match app.focused_panel() { - FocusedPanel::Chat => { - let (row, col) = app.chat_cursor(); - (row + 1, col + 1) - } - FocusedPanel::Thinking => { - let (row, col) = app.thinking_cursor(); - (row + 1, col + 1) - } - FocusedPanel::Input => { - let (row, col) = app.textarea().cursor(); - (row + 1, col + 1) - } - FocusedPanel::Code => { - let row = app - .code_view_scroll() - .map(|scroll| scroll.scroll + 1) - .unwrap_or(1); - (row, 1) - } - FocusedPanel::Files => (app.file_tree().cursor() + 1, 1), - }; - - format!("{}:{}", line, col) -} - -fn language_label_for_path(path: Option<&str>) -> &'static str { - let Some(path) = path else { - return "Plain Text"; - }; - - let Some(ext) = Path::new(path).extension().and_then(|ext| ext.to_str()) else { - return "Plain Text"; - }; - - match ext.to_ascii_lowercase().as_str() { - "rs" => "Rust 2024", - "py" => "Python 3", - "ts" => "TypeScript", - "tsx" => "TypeScript", - "js" => "JavaScript", - "jsx" => "JavaScript", - "go" => "Go", - "java" => "Java", - "kt" => "Kotlin", - "sh" => "Shell", - "bash" => "Shell", - "md" => "Markdown", - "toml" => "TOML", - "json" => "JSON", - "yaml" | "yml" => "YAML", - "html" => "HTML", - "css" => "CSS", - _ => "Plain Text", - } -} - -fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) { - let theme = app.theme(); - let area = centered_rect(60, 60, frame.area()); - frame.render_widget(Clear, area); - - let items: Vec = app - .available_providers - .iter() - .map(|provider| { - ListItem::new(Span::styled( - provider.to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.user_message_role, - )) - .add_modifier(Modifier::BOLD), - )) - }) - .collect(); - - let highlight_style = Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD); - - let list = List::new(items) - .block( - Block::default() - .title(Span::styled( - "Select Provider", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - )) - .add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - ))) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ) - .highlight_style(highlight_style) - .highlight_symbol("▶ "); - - let mut state = ListState::default(); - state.select(Some(app.selected_provider_index)); - frame.render_stateful_widget(list, area, &mut state); -} - -fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) { - let theme = app.theme(); - - // Get consent dialog state - let consent_state = match app.consent_dialog() { - Some(state) => state, - None => return, - }; - - // Create centered modal area - let area = centered_rect(70, 50, frame.area()); - frame.render_widget(Clear, area); - - // Build consent dialog content - let mut lines = vec![ - Line::from(vec![ - Span::styled( - "🔒 ", - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - )), - ), - Span::styled( - "Consent Required", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - )) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled( - consent_state.tool_name.clone(), - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.user_message_role, - )), - ), - ]), - Line::from(""), - ]; - - // Add data types if any - if !consent_state.data_types.is_empty() { - lines.push(Line::from(Span::styled( - "Data Access:", - Style::default().add_modifier(Modifier::BOLD), - ))); - for data_type in &consent_state.data_types { - lines.push(Line::from(vec![ - Span::raw(" • "), - Span::styled( - data_type, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ])); - } - lines.push(Line::from("")); - } - - // Add endpoints if any - if !consent_state.endpoints.is_empty() { - lines.push(Line::from(Span::styled( - "Endpoints:", - Style::default().add_modifier(Modifier::BOLD), - ))); - for endpoint in &consent_state.endpoints { - lines.push(Line::from(vec![ - Span::raw(" • "), - Span::styled( - endpoint, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ])); - } - lines.push(Line::from("")); - } - - // Add prompt - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Choose consent scope:", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - )) - .add_modifier(Modifier::BOLD), - )])); - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - "[1] ", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.mode_provider_selection, - )) - .add_modifier(Modifier::BOLD), - ), - Span::raw("Allow once "), - Span::styled( - "- Grant only for this operation", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ), - ])); - lines.push(Line::from(vec![ - Span::styled( - "[2] ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.mode_editing)) - .add_modifier(Modifier::BOLD), - ), - Span::raw("Allow session "), - Span::styled( - "- Grant for current session", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ), - ])); - lines.push(Line::from(vec![ - Span::styled( - "[3] ", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.mode_model_selection, - )) - .add_modifier(Modifier::BOLD), - ), - Span::raw("Allow always "), - Span::styled( - "- Grant permanently", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ), - ])); - lines.push(Line::from(vec![ - Span::styled( - "[4] ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.error)) - .add_modifier(Modifier::BOLD), - ), - Span::raw("Deny "), - Span::styled( - "- Reject this operation", - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ), - ])); - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - "[Esc] ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::BOLD), - ), - Span::raw("Cancel"), - ])); - - let paragraph = Paragraph::new(lines) - .block( - Block::default() - .title(Span::styled( - " Consent Dialog ", - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - )) - .add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - ))) - .style( - Style::default().bg(crate::color_convert::to_ratatui_color(&theme.background)), - ), - ) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }); - - frame.render_widget(paragraph, area); -} - -fn render_guidance_onboarding( - frame: &mut Frame<'_>, - area: Rect, - app: &ChatApp, - palette: GlassPalette, - theme: &Theme, -) { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(2), - ]) - .split(area); - - let step = app.onboarding_step(); - let total = app.onboarding_step_count(); - let bindings = app.keymap_bindings(); - let leader = app.keymap_leader().to_string(); - let (title, body_lines) = onboarding_section(app, theme, &bindings, &leader); - - let header = Paragraph::new(Line::from(vec![ - Span::styled( - format!("Getting started · Step {} of {}", step + 1, total), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - title, - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - ])) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .alignment(Alignment::Center); - frame.render_widget(header, layout[0]); - - let content = Paragraph::new(body_lines) - .style(Style::default().bg(palette.active).fg(palette.label)) - .wrap(Wrap { trim: true }); - frame.render_widget(content, layout[1]); - - let mut footer_spans = vec![ - Span::raw(" "), - Span::styled( - "Enter", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw("/"), - Span::styled( - "→", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" Next "), - ]; - if step > 0 { - footer_spans.push(Span::styled( - "Shift+Tab/←", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - )); - footer_spans.push(Span::raw(" Back ")); - } - footer_spans.push(Span::styled( - "Esc", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - )); - footer_spans.push(Span::raw(" Skip")); - - let footer = Paragraph::new(Line::from(footer_spans)) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .alignment(Alignment::Center); - frame.render_widget(footer, layout[2]); -} - -fn onboarding_section( - app: &ChatApp, - theme: &Theme, - bindings: &[KeymapBindingDescription], - leader: &str, -) -> (String, Vec>) { - let profile = app.current_keymap_profile(); - let profile_label = match profile { - KeymapProfile::Vim => "Vim", - KeymapProfile::Emacs => "Emacs", - KeymapProfile::Custom => "Custom", - }; - let step = app.onboarding_step(); - - let mut lines = Vec::new(); - - match step { - 0 => { - let title = format!("Focus & movement ({profile_label})"); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Focus shortcuts", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - for (label, command) in [ - ("Chat timeline", "focus.chat"), - ("Input editor", "focus.input"), - ("Files panel", "focus.files"), - ("Thinking panel", "focus.thinking"), - ("Code view", "focus.code"), - ] { - if let Some(line) = binding_line( - label, - binding_pair_string(bindings, command, Some(InputMode::Normal), leader), - theme, - ) { - lines.push(line); - } - } - lines.push(Line::from( - " Tab / Shift+Tab → cycle panels forward/backward", - )); - lines.push(Line::from(" Esc → return to Normal mode")); - lines.push(Line::from(" g g / Shift+G → jump to top / bottom")); - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - "Tip", - Style::default().add_modifier(Modifier::BOLD).fg( - crate::color_convert::to_ratatui_color(&theme.user_message_role), - ), - ), - Span::raw(": press Ctrl/Alt+5 to jump back to the input field."), - ])); - (title, lines) - } - 1 => { - let title = format!("Leader actions (leader = {leader})"); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Model & provider", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - for (label, command) in [ - ("Model picker", "model.open_all"), - ("Command palette", "palette.open"), - ("Switch provider", "provider.switch"), - ("Command mode", "mode.command"), - ] { - if let Some(line) = binding_line( - label, - binding_pair_string(bindings, command, Some(InputMode::Normal), leader), - theme, - ) { - lines.push(line); - } - } - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Layout", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - for (label, command) in [ - ("Split horizontal", "workspace.split_horizontal"), - ("Split vertical", "workspace.split_vertical"), - ("Focus left", "workspace.focus_left"), - ("Focus right", "workspace.focus_right"), - ("Focus up", "workspace.focus_up"), - ("Focus down", "workspace.focus_down"), - ] { - if let Some(line) = binding_line( - label, - binding_pair_string(bindings, command, Some(InputMode::Normal), leader), - theme, - ) { - lines.push(line); - } - } - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - "Tip", - Style::default().add_modifier(Modifier::BOLD).fg( - crate::color_convert::to_ratatui_color(&theme.user_message_role), - ), - ), - Span::raw(": use :keymap show to inspect every mapped chord."), - ])); - (title, lines) - } - _ => { - let title = "Search & next steps".to_string(); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Search shortcuts", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - lines.push(Line::from(" Ctrl+Shift+F → project search (ripgrep)")); - lines.push(Line::from(" Ctrl+Shift+P → symbol search across files")); - lines.push(Line::from( - " Ctrl+Shift+R → reveal active file in the tree", - )); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Commands", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - lines.push(Line::from( - " :tutorial → replay onboarding coach marks", - )); - lines.push(Line::from(" :keymap show → print the active bindings")); - lines.push(Line::from(" :limits → refresh usage & quotas")); - lines.push(Line::from( - " :privacy-enable / :privacy-disable ", - )); - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - "Reminder", - Style::default().add_modifier(Modifier::BOLD).fg( - crate::color_convert::to_ratatui_color(&theme.user_message_role), - ), - ), - Span::raw(": press ? anytime for the cheat sheet."), - ])); - (title, lines) - } - } -} - -fn render_guidance_cheatsheet( - frame: &mut Frame<'_>, - area: Rect, - app: &ChatApp, - palette: GlassPalette, - theme: &Theme, -) { - let bindings = app.keymap_bindings(); - let leader = app.keymap_leader().to_string(); - let sections = build_cheatsheet_sections(app, theme, &bindings, &leader); - let tabs = ["Focus & Modes", "Leader Actions", "Search & Commands"]; - let tab_index = app.help_tab_index().min(tabs.len().saturating_sub(1)); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(2), - ]) - .split(area); - - let mut tab_spans = Vec::new(); - for (index, title) in tabs.iter().enumerate() { - if index == tab_index { - tab_spans.push(Span::styled( - format!(" {} ", title), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .add_modifier(Modifier::BOLD), - )); - } else { - tab_spans.push(Span::styled( - format!(" {} ", title), - Style::default().fg(palette.label), - )); - } - if index < tabs.len() - 1 { - tab_spans.push(Span::raw(" │ ")); - } - } - let tabs_para = Paragraph::new(Line::from(tab_spans)) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .alignment(Alignment::Center); - frame.render_widget(tabs_para, layout[0]); - - let content = sections.get(tab_index).cloned().unwrap_or_default(); - let content_para = Paragraph::new(content) - .style(Style::default().bg(palette.active).fg(palette.label)) - .wrap(Wrap { trim: true }); - frame.render_widget(content_para, layout[1]); - - let nav_hint = Line::from(vec![ - Span::raw(" "), - Span::styled( - "Tab/→", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(":Next "), - Span::styled( - "Shift+Tab/←", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(":Prev "), - Span::styled( - format!("1-{}", tabs.len()), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(":Jump "), - Span::styled( - "Esc", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(":Close"), - ]); - let nav_para = Paragraph::new(nav_hint) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .alignment(Alignment::Center); - frame.render_widget(nav_para, layout[2]); -} - -fn build_cheatsheet_sections( - app: &ChatApp, - theme: &Theme, - bindings: &[KeymapBindingDescription], - leader: &str, -) -> Vec>> { - let profile = app.current_keymap_profile(); - let profile_label = match profile { - KeymapProfile::Vim => "Vim", - KeymapProfile::Emacs => "Emacs", - KeymapProfile::Custom => "Custom", - }; - - let mut focus = Vec::new(); - focus.push(Line::from("")); - focus.push(Line::from(vec![Span::styled( - format!("Active keymap · {profile_label}"), - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - focus.push(Line::from(vec![Span::styled( - format!("Leader key · {leader}"), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - )])); - focus.push(Line::from("")); - for (label, command) in [ - ("Files panel", "focus.files"), - ("Chat timeline", "focus.chat"), - ("Thinking panel", "focus.thinking"), - ("Code view", "focus.code"), - ("Input editor", "focus.input"), - ] { - if let Some(line) = binding_line( - label, - binding_pair_string(bindings, command, Some(InputMode::Normal), leader), - theme, - ) { - focus.push(line); - } - } - focus.push(Line::from( - " Tab / Shift+Tab → cycle panels forward/backward", - )); - focus.push(Line::from(" Esc → return to Normal mode")); - focus.push(Line::from(" g g / Shift+G → jump to top / bottom")); - if let Some(line) = binding_line( - "Send message", - binding_primary_string( - bindings, - "composer.submit", - Some(InputMode::Editing), - leader, - ), - theme, - ) { - focus.push(Line::from("")); - focus.push(Line::from(vec![Span::styled( - "Editing", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - focus.push(line); - } - - let mut leader_lines = Vec::new(); - leader_lines.push(Line::from("")); - leader_lines.push(Line::from(vec![Span::styled( - "Model & provider", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - for (label, command) in [ - ("Model picker", "model.open_all"), - ("Command palette", "palette.open"), - ("Switch provider", "provider.switch"), - ("Command mode", "mode.command"), - ] { - if let Some(line) = binding_line( - label, - binding_pair_string(bindings, command, Some(InputMode::Normal), leader), - theme, - ) { - leader_lines.push(line); - } - } - leader_lines.push(Line::from("")); - leader_lines.push(Line::from(vec![Span::styled( - "Layout", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - for (label, command) in [ - ("Split horizontal", "workspace.split_horizontal"), - ("Split vertical", "workspace.split_vertical"), - ("Focus left", "workspace.focus_left"), - ("Focus right", "workspace.focus_right"), - ("Focus up", "workspace.focus_up"), - ("Focus down", "workspace.focus_down"), - ] { - if let Some(line) = binding_line( - label, - binding_pair_string(bindings, command, Some(InputMode::Normal), leader), - theme, - ) { - leader_lines.push(line); - } - } - - let mut search_lines = vec![ - Line::from(""), - Line::from(vec![Span::styled( - "Search shortcuts", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )]), - Line::from(" Ctrl+Shift+F → project search (ripgrep)"), - Line::from(" Ctrl+Shift+P → symbol search across files"), - Line::from(" Ctrl+Shift+R → reveal active file in the tree"), - Line::from(""), - Line::from(vec![Span::styled( - "Slash commands", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )]), - Line::from(" :tutorial → replay onboarding coach marks"), - Line::from(" :keymap show → print the active bindings"), - Line::from(" :limits → refresh usage & quotas"), - Line::from(" :privacy-enable / :privacy-disable "), - Line::from(""), - ]; - - let privacy_snapshot = { - let cfg = app.config(); - ( - cfg.privacy.enable_remote_search && cfg.tools.web_search.enabled, - cfg.tools.code_exec.enabled, - cfg.privacy.cache_web_results, - cfg.privacy.require_consent_per_session, - cfg.privacy.encrypt_local_data, - cfg.privacy.retain_history_days, - ) - }; - let ( - remote_search_enabled, - code_exec_enabled, - cache_results, - consent_required, - encryption_enabled, - history_days, - ) = privacy_snapshot; - search_lines.push(Line::from(vec![Span::styled( - "Privacy overview", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(crate::color_convert::to_ratatui_color(&theme.info)), - )])); - search_lines.push(Line::from(format!( - " Web search → {}", - status_label(remote_search_enabled) - ))); - search_lines.push(Line::from(format!( - " Code execution → {}", - status_label(code_exec_enabled) - ))); - search_lines.push(Line::from(format!( - " Cache web results→ {}", - if cache_results { "Yes" } else { "No" } - ))); - search_lines.push(Line::from(format!( - " Consent required → {}", - status_label(consent_required) - ))); - search_lines.push(Line::from(format!( - " Encrypted storage→ {}", - status_label(encryption_enabled) - ))); - search_lines.push(Line::from(format!( - " History retention→ {} day(s)", - history_days - ))); - search_lines.push(Line::from("")); - search_lines.push(Line::from( - " :privacy-clear → clear encrypted data", - )); - search_lines.push(Line::from("")); - search_lines.push(Line::from(vec![ - Span::styled( - "Reminder", - Style::default().add_modifier(Modifier::BOLD).fg( - crate::color_convert::to_ratatui_color(&theme.user_message_role), - ), - ), - Span::raw(": press ? anytime to reopen this cheat sheet."), - ])); - - vec![focus, leader_lines, search_lines] -} - -fn binding_line(label: &str, binding: Option, theme: &Theme) -> Option> { - binding.map(|shortcut| { - Line::from(vec![ - Span::styled( - format!("{:<20}", label), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" → "), - Span::styled( - shortcut, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)), - ), - ]) - }) -} - -fn status_label(enabled: bool) -> &'static str { - if enabled { "Enabled" } else { "Disabled" } -} - -fn binding_pair_string( - bindings: &[KeymapBindingDescription], - command: &str, - preferred_mode: Option, - leader: &str, -) -> Option { - let (direct, leader_binding) = binding_variants(bindings, command, preferred_mode, leader); - match (direct, leader_binding) { - (Some(ref d), Some(ref l)) if d != l => Some(format!("{d} / {l}")), - (Some(value), _) => Some(value), - (_, Some(value)) => Some(value), - _ => None, - } -} - -fn binding_primary_string( - bindings: &[KeymapBindingDescription], - command: &str, - preferred_mode: Option, - leader: &str, -) -> Option { - let (direct, leader_binding) = binding_variants(bindings, command, preferred_mode, leader); - direct.or(leader_binding) -} - -fn binding_variants( - bindings: &[KeymapBindingDescription], - command: &str, - preferred_mode: Option, - leader: &str, -) -> (Option, Option) { - let mut candidates: Vec<&KeymapBindingDescription> = bindings - .iter() - .filter(|desc| desc.command == command) - .collect(); - if candidates.is_empty() { - return (None, None); - } - if let Some(mode) = preferred_mode { - candidates.sort_by_key(|desc| if desc.mode == mode { 0 } else { 1 }); - } - let mut direct = None; - let mut leader_binding = None; - for desc in &candidates { - let seq = format_sequence(&desc.sequence); - let starts_with_leader = desc - .sequence - .first() - .map(|token| token.eq_ignore_ascii_case(leader)) - .unwrap_or(false); - if starts_with_leader { - if leader_binding.is_none() { - leader_binding = Some(seq.clone()); - } - } else if direct.is_none() { - direct = Some(seq.clone()); - } - if direct.is_some() && leader_binding.is_some() { - break; - } - } - if direct.is_none() - && let Some(desc) = candidates.first() - { - let seq = format_sequence(&desc.sequence); - if !desc - .sequence - .first() - .map(|token| token.eq_ignore_ascii_case(leader)) - .unwrap_or(false) - { - direct = Some(seq.clone()); - } - } - if leader_binding.is_none() - && let Some(desc) = candidates.iter().find(|candidate| { - candidate - .sequence - .first() - .map(|token| token.eq_ignore_ascii_case(leader)) - .unwrap_or(false) - }) - { - leader_binding = Some(format_sequence(&desc.sequence)); - } - (direct, leader_binding) -} - -fn format_sequence(sequence: &[String]) -> String { - sequence.join(" ") -} - -#[allow(unreachable_code)] -fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { - let theme = app.theme(); - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); - let area = centered_rect(75, 70, frame.area()); - if area.width == 0 || area.height == 0 { - return; - } - - if !reduced && area.width > 2 && area.height > 2 { - let shadow = Rect::new( - area.x.saturating_add(1), - area.y.saturating_add(1), - area.width.saturating_sub(1), - area.height.saturating_sub(1), - ); - if shadow.width > 0 && shadow.height > 0 { - frame.render_widget( - Block::default().style(Style::default().bg(palette.shadow)), - shadow, - ); - } - } - - frame.render_widget(Clear, area); - - let container = Block::default() - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style(Style::default().bg(palette.active).fg(palette.label)); - let inner = container.inner(area); - frame.render_widget(container, area); - if inner.width == 0 || inner.height == 0 { - return; - } - - if matches!(app.guidance_overlay(), GuidanceOverlay::Onboarding) { - render_guidance_onboarding(frame, inner, app, palette, theme); - } else { - render_guidance_cheatsheet(frame, inner, app, palette, theme); - } -} -fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { - let theme = app.theme(); - let area = centered_rect(70, 70, frame.area()); - frame.render_widget(Clear, area); - - let sessions = app.saved_sessions(); - - if sessions.is_empty() { - let text = vec![ - Line::from(""), - Line::from("No saved sessions found."), - Line::from(""), - Line::from("Save your current session with :session save [name]"), - Line::from(""), - Line::from("Press Esc to close."), - ]; - - let paragraph = Paragraph::new(text) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ) - .block( - Block::default() - .title(Span::styled( - " Saved Sessions ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ) - .alignment(Alignment::Center); - - frame.render_widget(paragraph, area); - return; - } - - let items: Vec = sessions - .iter() - .enumerate() - .map(|(idx, session)| { - let name = session.name.as_deref().unwrap_or("Unnamed session"); - - let created = session - .created_at - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let age_hours = (now - created) / 3600; - let age_str = if age_hours < 1 { - "< 1h ago".to_string() - } else if age_hours < 24 { - format!("{}h ago", age_hours) - } else { - format!("{}d ago", age_hours / 24) - }; - - let info = format!( - "{} messages · {} · {}", - session.message_count, session.model, age_str - ); - - let is_selected = idx == app.selected_session_index(); - let style = if is_selected { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)) - }; - - let info_style = if is_selected { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - } else { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - }; - - let desc_style = if is_selected { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .add_modifier(Modifier::ITALIC) - } else { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC) - }; - - let mut lines = vec![Line::from(Span::styled(name, style))]; - - // Add description if available and not empty - if let Some(description) = &session.description - && !description.is_empty() - { - lines.push(Line::from(Span::styled( - format!(" \"{}\"", description), - desc_style, - ))); - } - - // Add metadata line - lines.push(Line::from(Span::styled(format!(" {}", info), info_style))); - - ListItem::new(lines) - }) - .collect(); - - let list = List::new(items).block( - Block::default() - .title(Span::styled( - format!(" Saved Sessions ({}) ", sessions.len()), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info))) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ); - - let footer = Paragraph::new(vec![ - Line::from(""), - Line::from("↑/↓ or j/k: Navigate · Enter: Load · d: Delete · Esc: Cancel"), - ]) - .alignment(Alignment::Center) - .style( - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .bg(crate::color_convert::to_ratatui_color(&theme.background)), - ); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(5), Constraint::Length(3)]) - .split(area); - - frame.render_widget(list, layout[0]); - frame.render_widget(footer, layout[1]); -} - -fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { - let theme = app.theme(); - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); - let area = frame.area(); - if area.width == 0 || area.height == 0 { - return; - } - - let themes = app.available_themes(); - let current_theme_name = if app.is_high_contrast_enabled() { - app.base_theme_name() - } else { - theme.name.as_str() - }; - - let max_width: u16 = 80; - let min_width: u16 = 40; - let mut width = area.width.min(max_width); - if area.width >= min_width { - width = width.max(min_width); - } else { - width = area.width; - } - width = width.max(1); - - let visible_rows = themes.len().clamp(1, 12) as u16; - let mut height = visible_rows.saturating_mul(2).saturating_add(8); - height = height.clamp(8, area.height); - - let x = area.x + (area.width.saturating_sub(width)) / 2; - let mut y = area.y + (area.height.saturating_sub(height)) / 3; - if y < area.y { - y = area.y; - } - - let popup_area = Rect::new(x, y, width, height); - if !reduced && popup_area.width > 2 && popup_area.height > 2 { - let shadow_area = Rect::new( - popup_area.x.saturating_add(1), - popup_area.y.saturating_add(1), - popup_area.width.saturating_sub(1), - popup_area.height.saturating_sub(1), - ); - if shadow_area.width > 0 && shadow_area.height > 0 { - frame.render_widget( - Block::default().style(Style::default().bg(palette.shadow)), - shadow_area, - ); - } - } - - frame.render_widget(Clear, popup_area); - - let title_spans = vec![ - Span::styled( - " Theme Selector ", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("· Current: {}", current_theme_name), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM), - ), - ]; - - let container = Block::default() - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .title(Line::from(title_spans)) - .title_style(Style::default().fg(palette.label)) - .style(Style::default().bg(palette.active).fg(palette.label)); - - let inner = container.inner(popup_area); - frame.render_widget(container, popup_area); - if inner.width == 0 || inner.height == 0 { - return; - } - - if themes.is_empty() { - let empty = Paragraph::new(Line::from(Span::styled( - "No themes available · Press Esc to close", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM | Modifier::ITALIC), - ))) - .alignment(Alignment::Center) - .style(Style::default().bg(palette.active).fg(palette.label)); - frame.render_widget(empty, inner); - return; - } - - let all_themes = owlen_core::load_all_themes(); - let built_in = owlen_core::built_in_themes(); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(6), Constraint::Length(2)]) - .split(inner); - - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) - .split(layout[0]); - - let left_sections = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(3)]) - .split(columns[0]); - - let search_query = app.model_search_query().trim().to_string(); - let search_active = !search_query.is_empty(); - let matches = app.visible_model_count(); - - let search_prefix = Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM); - let bracket_style = Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM); - let caret_style = if search_active { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM) - }; - - let mut search_spans = Vec::new(); - search_spans.push(Span::styled("Search ▸ ", search_prefix)); - search_spans.push(Span::styled("[", bracket_style)); - search_spans.push(Span::styled(" ", bracket_style)); - - if search_active { - search_spans.push(Span::styled( - search_query.clone(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD), - )); - } else { - search_spans.push(Span::styled( - "Type to search…", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM | Modifier::ITALIC), - )); - } - - search_spans.push(Span::styled(" ", bracket_style)); - search_spans.push(Span::styled("▎", caret_style)); - search_spans.push(Span::styled(" ", bracket_style)); - search_spans.push(Span::styled("]", bracket_style)); - search_spans.push(Span::raw(" ")); - let suffix_label = if search_active { "match" } else { "model" }; - search_spans.push(Span::styled( - format!( - "({} {}{})", - matches, - suffix_label, - if matches == 1 { "" } else { "s" } - ), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM), - )); - - let search_line = Line::from(search_spans); - - let instruction_line = if search_active { - Line::from(vec![ - Span::styled("Backspace", Style::default().fg(palette.label)), - Span::raw(": delete "), - Span::styled("Ctrl+U", Style::default().fg(palette.label)), - Span::raw(": clear "), - Span::styled("Enter", Style::default().fg(palette.label)), - Span::raw(": select "), - Span::styled("Esc", Style::default().fg(palette.label)), - Span::raw(": close"), - ]) - } else { - Line::from(vec![ - Span::styled("Enter", Style::default().fg(palette.label)), - Span::raw(": select "), - Span::styled("Space", Style::default().fg(palette.label)), - Span::raw(": toggle provider "), - Span::styled("Esc", Style::default().fg(palette.label)), - Span::raw(": close"), - ]) - }; - - if !left_sections.is_empty() { - let search_paragraph = Paragraph::new(vec![search_line, instruction_line]) - .style(Style::default().bg(palette.highlight).fg(palette.label)); - frame.render_widget(search_paragraph, left_sections[0]); - } - - let mut items: Vec = Vec::with_capacity(themes.len()); - for theme_name in themes.iter() { - let is_current = theme_name == current_theme_name; - let descriptor = if built_in.contains_key(theme_name) { - "Built-in theme" - } else { - "Custom theme" - }; - - let mut title = format!(" {}", theme_name); - if is_current { - title.push_str(" ✓"); - } - - let mut title_style = Style::default().fg(palette.label); - if is_current { - title_style = title_style - .fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - )) - .add_modifier(Modifier::BOLD); - } - - let metadata_style = Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM); - - let lines = vec![ - Line::from(Span::styled(title, title_style)), - Line::from(vec![ - Span::raw(" "), - Span::styled(descriptor, metadata_style), - ]), - ]; - - items.push( - ListItem::new(lines).style(Style::default().bg(palette.active).fg(palette.label)), - ); - } - - let highlight_style = Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD); - - let mut state = ListState::default(); - let selected_index = app - .selected_theme_index() - .min(themes.len().saturating_sub(1)); - state.select(Some(selected_index)); - - if left_sections.len() >= 2 { - let list = List::new(items) - .highlight_style(highlight_style) - .highlight_symbol(" ") - .style(Style::default().bg(palette.active).fg(palette.label)); - - frame.render_stateful_widget(list, left_sections[1], &mut state); - } - - if columns.len() >= 2 { - let preview_area = columns[1]; - if preview_area.width > 0 && preview_area.height > 0 - && let Some(selected_name) = themes.get(selected_index) - { - let preview_theme = all_themes - .get(selected_name.as_str()) - .cloned() - .or_else(|| all_themes.get(current_theme_name).cloned()) - .unwrap_or_else(|| theme.clone()); - render_theme_preview( - frame, - preview_area, - &preview_theme, - preview_theme.name == theme.name, - app.layer_settings(), - ); - } - } - - let footer = Paragraph::new(Line::from(Span::styled( - "↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc: Cancel", - Style::default().fg(palette.label), - ))) - .alignment(Alignment::Center) - .style(Style::default().bg(palette.highlight).fg(palette.label)); - - frame.render_widget(footer, layout[1]); -} - -fn render_theme_preview( - frame: &mut Frame<'_>, - area: Rect, - preview_theme: &Theme, - is_active: bool, - layers: &LayerSettings, -) { - if area.width < 10 || area.height < 5 { - return; - } - - frame.render_widget(Clear, area); - let preview_palette = GlassPalette::for_theme(preview_theme, layers); - let mut title = format!("Preview · {}", preview_theme.name); - if is_active { - title.push_str(" (active)"); - } - - let block = Block::default() - .borders(Borders::NONE) - .padding(Padding::new(2, 2, 1, 1)) - .title(Line::from(Span::styled( - title, - Style::default() - .fg(preview_palette.label) - .add_modifier(Modifier::BOLD), - ))) - .style( - Style::default() - .bg(preview_palette.active) - .fg(preview_palette.label), - ); - - let inner = block.inner(area); - frame.render_widget(block, area); - if inner.width == 0 || inner.height == 0 { - return; - } - - let mut lines: Vec = Vec::new(); - lines.push(Line::from(vec![ - Span::styled( - "You", - Style::default() - .fg(to_ratatui_color(&preview_theme.user_message_role)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" » "), - Span::styled( - "Let's try this palette.", - Style::default().fg(to_ratatui_color(&preview_theme.text)), - ), - ])); - lines.push(Line::from(vec![ - Span::styled( - "Owlen", - Style::default() - .fg(to_ratatui_color(&preview_theme.assistant_message_role)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" » "), - Span::styled( - "Looks sharp and legible!", - Style::default().fg(to_ratatui_color(&preview_theme.text)), - ), - ])); - lines.push(Line::raw("")); - - for (label, fg, bg) in [ - ( - "Focus border", - preview_theme.background, - preview_theme.focused_panel_border, - ), - ( - "Selection", - preview_theme.selection_fg, - preview_theme.selection_bg, - ), - ("Info", preview_theme.background, preview_theme.info), - ("Error", preview_theme.background, preview_theme.error), - ] { - lines.push(Line::from(vec![ - Span::styled( - " ", - Style::default() - .bg(to_ratatui_color(&bg)) - .fg(to_ratatui_color(&fg)), - ), - Span::raw(" "), - Span::styled( - label.to_string(), - Style::default() - .fg(preview_palette.label) - .add_modifier(Modifier::DIM), - ), - ])); - } - - let gauge_line = Line::from(vec![ - Span::styled( - "Context ", - Style::default() - .fg(to_ratatui_color(&preview_theme.info)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "██████", - Style::default() - .bg(to_ratatui_color(&preview_theme.info)) - .fg(to_ratatui_color(&preview_theme.background)), - ), - Span::styled( - "──", - Style::default() - .fg(preview_palette.label) - .add_modifier(Modifier::DIM), - ), - Span::raw(" "), - Span::styled( - "Usage ", - Style::default() - .fg(to_ratatui_color(&preview_theme.mode_help)) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "████", - Style::default() - .bg(to_ratatui_color(&preview_theme.mode_help)) - .fg(to_ratatui_color(&preview_theme.background)), - ), - Span::styled( - "────", - Style::default() - .fg(preview_palette.label) - .add_modifier(Modifier::DIM), - ), - ]); - lines.push(Line::raw("")); - lines.push(gauge_line); - - let paragraph = Paragraph::new(lines) - .style( - Style::default() - .bg(preview_palette.active) - .fg(preview_palette.label), - ) - .wrap(Wrap { trim: true }); - frame.render_widget(paragraph, inner); -} - -fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { - let theme = app.theme(); - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); - let suggestions = app.command_suggestions(); - let buffer = app.command_buffer(); - let area = frame.area(); - - if area.width == 0 || area.height == 0 { - return; - } - - let visible_count = suggestions.len().clamp(1, 8) as u16; - let mut height = visible_count.saturating_mul(2).saturating_add(6); - height = height.clamp(6, area.height); - - let max_width: u16 = 80; - let min_width: u16 = 40; - let mut width = area.width.min(max_width); - if area.width >= min_width { - width = width.max(min_width); - } else { - width = area.width; - } - width = width.max(1); - - let x = area.x + (area.width.saturating_sub(width)) / 2; - let mut y = area.y + (area.height.saturating_sub(height)) / 3; - if y < area.y { - y = area.y; - } - let popup_area = Rect::new(x, y, width, height); - - if !reduced && popup_area.width > 2 && popup_area.height > 2 { - let shadow = Rect::new( - popup_area.x.saturating_add(1), - popup_area.y.saturating_add(1), - popup_area.width.saturating_sub(1), - popup_area.height.saturating_sub(1), - ); - if shadow.width > 0 && shadow.height > 0 { - frame.render_widget( - Block::default().style(Style::default().bg(palette.shadow)), - shadow, - ); - } - } - - frame.render_widget(Clear, popup_area); - - let header = Line::from(vec![ - Span::styled( - " Command Palette ", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "Ctrl+P", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM), - ), - ]); - - let block = Block::default() - .title(header) - .title_style(Style::default().fg(palette.label)) - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style(Style::default().bg(palette.active).fg(palette.label)); - - let inner = block.inner(popup_area); - frame.render_widget(block, popup_area); - - if inner.width == 0 || inner.height == 0 { - return; - } - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(2), - ]) - .split(inner); - - let input = Paragraph::new(Line::from(vec![ - Span::styled( - ":", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(buffer), - ])) - .style(Style::default().bg(palette.highlight).fg(palette.label)); - frame.render_widget(input, layout[0]); - - let (selected_index, selected_preview) = if suggestions.is_empty() { - (None, None) - } else { - let idx = app - .selected_suggestion() - .min(suggestions.len().saturating_sub(1)); - let preview = suggestions - .get(idx) - .and_then(|suggestion| suggestion.preview.as_ref()); - (Some(idx), preview) - }; - - let show_preview = selected_preview.is_some() && layout[1].width > 60; - let (list_area, preview_area) = if show_preview { - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(62), Constraint::Percentage(38)]) - .split(layout[1]); - (columns[0], Some(columns[1])) - } else { - (layout[1], None) - }; - - if suggestions.is_empty() { - let placeholder = Paragraph::new(Line::from(Span::styled( - "No matches — keep typing", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::ITALIC), - ))) - .alignment(Alignment::Center) - .style(Style::default().bg(palette.active).fg(palette.label)); - frame.render_widget(placeholder, layout[1]); - } else { - let highlight = Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)); - - let mut items: Vec = Vec::new(); - let mut previous_group: Option = None; - let accent = Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)); - - for (idx, suggestion) in suggestions.iter().enumerate() { - let mut lines: Vec = Vec::new(); - if previous_group != Some(suggestion.group) { - lines.push(Line::from(Span::styled( - palette_group_label(suggestion.group), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD | Modifier::DIM), - ))); - previous_group = Some(suggestion.group); - } - - let mut label_spans = vec![ - Span::styled( - if Some(idx) == selected_index { - "›" - } else { - " " - }, - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM), - ), - Span::raw(" "), - Span::styled( - suggestion.label.clone(), - Style::default().add_modifier(Modifier::BOLD), - ), - ]; - - if let Some(binding) = &suggestion.keybinding { - label_spans.push(Span::raw(" ")); - label_spans.push(Span::styled(binding.clone(), accent)); - } - lines.push(Line::from(label_spans)); - - if let Some(detail) = &suggestion.detail { - lines.push(Line::from(Span::styled( - format!(" {}", detail), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM), - ))); - } - - let mut meta_spans: Vec = vec![Span::raw(" ")]; - let mut has_meta = false; - if let Some(category) = &suggestion.category { - meta_spans.push(Span::styled(category.clone(), accent)); - has_meta = true; - } - if !suggestion.modes.is_empty() { - if has_meta { - meta_spans.push(Span::raw(" · ")); - } - meta_spans.push(Span::styled( - format!("Modes: {}", suggestion.modes.join(", ")), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::ITALIC), - )); - has_meta = true; - } - if !suggestion.tags.is_empty() { - if has_meta { - meta_spans.push(Span::raw(" · ")); - } - meta_spans.push(Span::styled( - format!("#{}", suggestion.tags.join(" #")), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM), - )); - has_meta = true; - } - if has_meta { - lines.push(Line::from(meta_spans)); - } - - let item = - ListItem::new(lines).style(Style::default().bg(palette.active).fg(palette.label)); - items.push(item); - } - - let mut list_state = ListState::default(); - list_state.select(selected_index); - - let list = List::new(items) - .highlight_style(highlight) - .style(Style::default().bg(palette.active).fg(palette.label)); - - frame.render_stateful_widget(list, list_area, &mut list_state); - - if let (Some(area), Some(preview)) = (preview_area, selected_preview) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - ))) - .title(Span::styled( - format!(" {} ", preview.title), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )) - .style(Style::default().bg(palette.active).fg(palette.label)); - - frame.render_widget(block.clone(), area); - let inner = block.inner(area); - if inner.width > 0 && inner.height > 0 { - let lines: Vec = preview - .body - .iter() - .map(|line| Line::from(Span::raw(line.clone()))) - .collect(); - let preview_paragraph = Paragraph::new(lines) - .wrap(Wrap { trim: true }) - .style(Style::default().bg(palette.active).fg(palette.label)); - frame.render_widget(preview_paragraph, inner); - } - } - } - - let instructions = "Enter: run · Tab: autocomplete · /tag filter · Esc: cancel"; - - let footer = Paragraph::new(Line::from(Span::styled( - instructions, - Style::default().fg(palette.label), - ))) - .alignment(Alignment::Center) - .style(Style::default().bg(palette.highlight).fg(palette.label)); - frame.render_widget(footer, layout[2]); -} - -fn palette_group_label(group: PaletteGroup) -> &'static str { - match group { - PaletteGroup::History => "History", - PaletteGroup::Command => "Commands", - PaletteGroup::Model => "Models", - PaletteGroup::Provider => "Providers", - } -} - -fn render_repo_search(frame: &mut Frame<'_>, app: &mut ChatApp) { - let theme = app.theme().clone(); - let popup = centered_rect(70, 70, frame.area()); - frame.render_widget(Clear, popup); - - let block = Block::default() - .title(Span::styled( - " Repo Search · ripgrep ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - ))) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ); - - frame.render_widget(block.clone(), popup); - let inner = block.inner(popup); - if inner.width == 0 || inner.height == 0 { - return; - } - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(1), - Constraint::Min(1), - ]) - .split(inner); - - let (query, running, dirty, status_line, error_line) = { - let state = app.repo_search(); - ( - state.query_input().to_string(), - state.running(), - state.dirty(), - state.status().cloned(), - state.error().cloned(), - ) - }; - - { - let viewport = layout[2].height.saturating_sub(1) as usize; - app.repo_search_mut().set_viewport_height(viewport.max(1)); - } - - let state = app.repo_search(); - let mut query_spans = vec![Span::styled( - "Pattern: ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )]; - let mut query_style = Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)); - if dirty { - query_style = query_style.add_modifier(Modifier::ITALIC); - } - if query.is_empty() { - query_spans.push(Span::styled( - "", - query_style.add_modifier(Modifier::DIM), - )); - } else { - query_spans.push(Span::styled(query.clone(), query_style)); - } - if running { - query_spans.push(Span::styled( - " ⟳ searching…", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::ITALIC), - )); - } - - let query_para = Paragraph::new(Line::from(query_spans)).block( - Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - ))), - ); - frame.render_widget(query_para, layout[0]); - - let status_span = if let Some(err) = error_line { - Span::styled( - err, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.error)), - ) - } else if let Some(status) = status_line { - Span::styled( - status, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - ) - } else { - Span::styled( - "Enter=search Alt+Enter=scratch Esc=cancel", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - ) - }; - let status_para = Paragraph::new(Line::from(status_span)) - .alignment(Alignment::Left) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ); - frame.render_widget(status_para, layout[1]); - - let rows = state.visible_rows(); - let files = state.files(); - let selected_row = state.selected_row_index(); - let mut items: Vec = Vec::new(); - - for (offset, row) in rows.iter().enumerate() { - let absolute_index = state.scroll_top() + offset; - match &row.kind { - RepoSearchRowKind::FileHeader => { - let file = &files[row.file_index]; - let mut spans = vec![Span::styled( - file.display.clone(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)) - .add_modifier(Modifier::BOLD), - )]; - if !file.matches.is_empty() { - spans.push(Span::styled( - format!(" ({} matches)", file.matches.len()), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )); - } - items.push( - ListItem::new(Line::from(spans)).style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ), - ); - } - RepoSearchRowKind::Match { match_index } => { - let file = &files[row.file_index]; - if let Some(m) = file.matches.get(*match_index) { - let is_selected = absolute_index == selected_row; - let prefix_style = if is_selected { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM) - }; - let mut spans = vec![Span::styled( - format!(" {:>6}:{:<3} ", m.line_number, m.column), - prefix_style, - )]; - if is_selected { - spans.push(Span::styled( - m.preview.clone(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD), - )); - } else if let Some(matched) = &m.matched { - if let Some(idx) = m.preview.find(matched) { - let head = &m.preview[..idx]; - let tail = &m.preview[idx + matched.len()..]; - if !head.is_empty() { - spans.push(Span::styled( - head.to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - spans.push(Span::styled( - matched.to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )); - if !tail.is_empty() { - spans.push(Span::styled( - tail.to_string(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - } else { - spans.push(Span::styled( - m.preview.clone(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - } else { - spans.push(Span::styled( - m.preview.clone(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - - let item_style = if is_selected { - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)) - }; - items.push(ListItem::new(Line::from(spans)).style(item_style)); - } - } - } - } - - if items.is_empty() { - let placeholder = if state.running() { - "Searching…" - } else if state.query_input().is_empty() { - "Type a pattern and press Enter" - } else { - "No results" - }; - items.push( - ListItem::new(Line::from(vec![Span::styled( - placeholder, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM | Modifier::ITALIC), - )])) - .style(Style::default().bg(crate::color_convert::to_ratatui_color(&theme.background))), - ); - } - - let list = List::new(items).block(Block::default().borders(Borders::TOP).border_style( - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - )), - )); - - frame.render_widget(list, layout[2]); -} - -fn render_symbol_search(frame: &mut Frame<'_>, app: &mut ChatApp) { - let theme = app.theme().clone(); - let area = centered_rect(70, 70, frame.area()); - frame.render_widget(Clear, area); - - let block = Block::default() - .title(Span::styled( - " Symbol Search · tree-sitter ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.focused_panel_border, - ))) - .style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ); - - frame.render_widget(block.clone(), area); - let inner = block.inner(area); - if inner.width == 0 || inner.height == 0 { - return; - } - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(1), - Constraint::Min(1), - ]) - .split(inner); - - let (query, running, status_text, error_text, filtered, total) = { - let state = app.symbol_search(); - ( - state.query().to_string(), - state.is_running(), - state.status().cloned(), - state.error().cloned(), - state.filtered_len(), - state.items().len(), - ) - }; - - { - let viewport = layout[2].height.saturating_sub(1) as usize; - app.symbol_search_mut().set_viewport_height(viewport.max(1)); - } - - let state = app.symbol_search(); - let mut query_spans = vec![Span::styled( - "Filter: ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )]; - if query.is_empty() { - query_spans.push(Span::styled( - "", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::ITALIC), - )); - } else { - query_spans.push(Span::styled( - query.clone(), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)), - )); - } - if running { - query_spans.push(Span::styled( - " ⟳ indexing…", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::ITALIC), - )); - } - - let query_para = Paragraph::new(Line::from(query_spans)).block( - Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - ))), - ); - frame.render_widget(query_para, layout[0]); - - let mut status_spans = Vec::new(); - if let Some(err) = error_text.as_ref() { - status_spans.push(Span::styled( - err, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.error)), - )); - } else if let Some(status) = status_text.as_ref() { - status_spans.push(Span::styled( - status, - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - )); - } else { - status_spans.push(Span::styled( - "Type to filter · Enter=jump · Esc=close", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )); - } - - if error_text.is_none() { - status_spans.push(Span::styled( - format!(" {} of {} symbols", filtered, total), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )); - } - - let status_para = Paragraph::new(Line::from(status_spans)).style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)), - ); - frame.render_widget(status_para, layout[1]); - - let visible = state.visible_indices(); - let items = state.items(); - let selected_idx = state.selected_filtered_index(); - let mut list_items: Vec = Vec::new(); - - for &item_index in visible.iter() { - if let Some(entry) = items.get(item_index) { - let is_selected = selected_idx == Some(item_index); - let mut spans = Vec::new(); - let icon_style = if is_selected { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.info)) - }; - spans.push(Span::styled(format!(" {} ", entry.kind.icon()), icon_style)); - let name_style = if is_selected { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)) - }; - spans.push(Span::styled(entry.name.clone(), name_style)); - spans.push(Span::raw(" ")); - let path_style = if is_selected { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::DIM) - } else { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM) - }; - spans.push(Span::styled( - format!("{}:{}", entry.display_path, entry.line), - path_style, - )); - - let item_style = if is_selected { - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.background)) - .fg(crate::color_convert::to_ratatui_color(&theme.text)) - }; - - list_items.push(ListItem::new(Line::from(spans)).style(item_style)); - } - } - - if list_items.is_empty() { - let placeholder = if running { - "Indexing symbols…" - } else if query.is_empty() { - "No symbols discovered" - } else { - "No symbols match filter" - }; - list_items.push( - ListItem::new(Line::from(vec![Span::styled( - placeholder, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM | Modifier::ITALIC), - )])) - .style(Style::default().bg(crate::color_convert::to_ratatui_color(&theme.background))), - ); - } - - let list = List::new(list_items).block(Block::default().borders(Borders::TOP).border_style( - Style::default().fg(crate::color_convert::to_ratatui_color( - &theme.unfocused_panel_border, - )), - )); - - frame.render_widget(list, layout[2]); -} - -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let vertical = Layout::vertical([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .flex(Flex::Center) - .split(area); - - Layout::horizontal([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .flex(Flex::Center) - .split(vertical[1])[1] -} - -/// Format tool output JSON into a nice human-readable format -pub(crate) fn format_tool_output(content: &str) -> String { - // Try to parse as JSON - if let Ok(json) = serde_json::from_str::(content) { - let mut output = String::new(); - let mut content_found = false; - - // Extract query if present - if let Some(query) = json.get("query").and_then(|v| v.as_str()) { - output.push_str(&format!("Query: \"{}\"\n\n", query)); - content_found = true; - } - - // Extract results array - if let Some(results) = json.get("results").and_then(|v| v.as_array()) { - content_found = true; - if results.is_empty() { - output.push_str("No results found"); - return output; - } - - for (i, result) in results.iter().enumerate() { - // Title - if let Some(title) = result.get("title").and_then(|v| v.as_str()) { - // Strip HTML tags from title - let clean_title = title.replace("", "").replace("", ""); - output.push_str(&format!("{}. {}\n", i + 1, clean_title)); - } - - // Source and date (if available) - let mut meta = Vec::new(); - if let Some(source) = result.get("source").and_then(|v| v.as_str()) { - meta.push(format!("📰 {}", source)); - } - if let Some(date) = result.get("date").and_then(|v| v.as_str()) { - // Simplify date format - if let Some(simple_date) = date.split('T').next() { - meta.push(format!("📅 {}", simple_date)); - } - } - if !meta.is_empty() { - output.push_str(&format!(" {}\n", meta.join(" • "))); - } - - // Snippet (truncated if too long) - if let Some(snippet) = result.get("snippet").and_then(|v| v.as_str()) - && !snippet.is_empty() - { - // Strip HTML tags - let clean_snippet = snippet - .replace("", "") - .replace("", "") - .replace("'", "'") - .replace(""", "\""); - - // Truncate if too long - let truncated = if clean_snippet.len() > 200 { - format!("{}...", &clean_snippet[..197]) - } else { - clean_snippet - }; - output.push_str(&format!(" {}\n", truncated)); - } - - // URL (shortened if too long) - if let Some(url) = result.get("url").and_then(|v| v.as_str()) { - let display_url = if url.len() > 80 { - format!("{}...", &url[..77]) - } else { - url.to_string() - }; - output.push_str(&format!(" 🔗 {}\n", display_url)); - } - - output.push('\n'); - } - - // Add total count - if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) { - output.push_str(&format!("Found {} result(s)", total)); - } - } else if let Some(result) = json.get("result").and_then(|v| v.as_str()) { - content_found = true; - output.push_str(result); - } else if let Some(error) = json.get("error").and_then(|v| v.as_str()) { - content_found = true; - // Handle error results - output.push_str(&format!("❌ Error: {}", error)); - } - - if content_found { - output - } else { - content.to_string() - } - } else { - // If not JSON, return as-is - content.to_string() - } -} diff --git a/crates/owlen-tui/src/widgets/mod.rs b/crates/owlen-tui/src/widgets/mod.rs deleted file mode 100644 index e267d4c..0000000 --- a/crates/owlen-tui/src/widgets/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Reusable widgets composed specifically for the Owlen TUI. - -pub mod model_picker; diff --git a/crates/owlen-tui/src/widgets/model_picker.rs b/crates/owlen-tui/src/widgets/model_picker.rs deleted file mode 100644 index 0eecf12..0000000 --- a/crates/owlen-tui/src/widgets/model_picker.rs +++ /dev/null @@ -1,896 +0,0 @@ -use std::collections::HashSet; - -use owlen_core::provider::{AnnotatedModelInfo, ProviderStatus, ProviderType}; -use owlen_core::types::ModelInfo; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, block::Padding}, -}; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -use crate::chat_app::{ - ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo, - ModelSelectorItemKind, -}; -use crate::color_convert::to_ratatui_color; -use crate::glass::GlassPalette; - -/// Filtering modes for the model picker popup. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -pub enum FilterMode { - #[default] - All, - LocalOnly, - CloudOnly, - Available, -} - -pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { - let theme = app.theme(); - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); - let area = frame.area(); - if area.width == 0 || area.height == 0 { - return; - } - - let selector_items = app.model_selector_items(); - if selector_items.is_empty() { - return; - } - - let search_query = app.model_search_query().trim().to_string(); - let search_active = !search_query.is_empty(); - - let max_width = area.width.min(90); - let min_width = area.width.min(56); - let width = area.width.min(max_width).max(min_width).max(1); - - let visible_models = app.visible_model_count(); - let min_rows: usize = if search_active { 5 } else { 4 }; - let max_rows: usize = 12; - let row_estimate = visible_models.max(min_rows).min(max_rows); - let mut height = (row_estimate as u16) * 3 + 8; - let min_height = area.height.clamp(8, 12); - let max_height = area.height.min(32); - height = height.clamp(min_height, max_height); - - let x = area.x + (area.width.saturating_sub(width)) / 2; - let mut y = area.y + (area.height.saturating_sub(height)) / 3; - if y < area.y { - y = area.y; - } - - let popup_area = Rect::new(x, y, width, height); - if !reduced && popup_area.width > 2 && popup_area.height > 2 { - let shadow_area = Rect::new( - popup_area.x.saturating_add(1), - popup_area.y.saturating_add(1), - popup_area.width.saturating_sub(1), - popup_area.height.saturating_sub(1), - ); - if shadow_area.width > 0 && shadow_area.height > 0 { - frame.render_widget( - Block::default().style(Style::default().bg(palette.shadow)), - shadow_area, - ); - } - } - frame.render_widget(Clear, popup_area); - - let mut title_spans = vec![ - Span::styled( - " Model Selector ", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("· Provider: {}", app.selected_provider), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM), - ), - ]; - if app.model_filter_mode() != FilterMode::All { - title_spans.push(Span::raw(" ")); - title_spans.push(filter_badge(app.model_filter_mode(), theme)); - } - - let block = Block::default() - .title(Line::from(title_spans)) - .title_style(Style::default().fg(palette.label)) - .borders(Borders::NONE) - .padding(if reduced { - Padding::new(1, 1, 0, 0) - } else { - Padding::new(2, 2, 1, 1) - }) - .style(Style::default().bg(palette.active).fg(palette.label)); - - let inner = block.inner(popup_area); - frame.render_widget(block, popup_area); - if inner.width == 0 || inner.height == 0 { - return; - } - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(2), - ]) - .split(inner); - - let matches = app.visible_model_count(); - let search_prefix = Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM); - let bracket_style = Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM); - let caret_style = if search_active { - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM) - }; - - let mut search_spans = Vec::new(); - search_spans.push(Span::styled("Search ▸ ", search_prefix)); - search_spans.push(Span::styled("[", bracket_style)); - search_spans.push(Span::styled(" ", bracket_style)); - - if search_active { - search_spans.push(Span::styled( - search_query.clone(), - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD), - )); - } else { - search_spans.push(Span::styled( - "Type to search…", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM | Modifier::ITALIC), - )); - } - - search_spans.push(Span::styled(" ", bracket_style)); - search_spans.push(Span::styled("▎", caret_style)); - search_spans.push(Span::styled(" ", bracket_style)); - search_spans.push(Span::styled("]", bracket_style)); - search_spans.push(Span::raw(" ")); - let suffix_label = if search_active { "match" } else { "model" }; - search_spans.push(Span::styled( - format!( - "({} {}{})", - matches, - suffix_label, - if matches == 1 { "" } else { "s" } - ), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::DIM), - )); - - let search_line = Line::from(search_spans); - - let instruction_line = if search_active { - Line::from(vec![ - Span::styled("Backspace", Style::default().fg(palette.label)), - Span::raw(": delete "), - Span::styled("Ctrl+U", Style::default().fg(palette.label)), - Span::raw(": clear "), - Span::styled("Enter", Style::default().fg(palette.label)), - Span::raw(": select "), - Span::styled("Esc", Style::default().fg(palette.label)), - Span::raw(": close"), - ]) - } else { - Line::from(vec![ - Span::styled("Enter", Style::default().fg(palette.label)), - Span::raw(": select "), - Span::styled("Space", Style::default().fg(palette.label)), - Span::raw(": toggle provider "), - Span::styled("Esc", Style::default().fg(palette.label)), - Span::raw(": close"), - ]) - }; - - let search_paragraph = Paragraph::new(vec![search_line, instruction_line]) - .style(Style::default().bg(palette.highlight).fg(palette.label)); - frame.render_widget(search_paragraph, layout[0]); - - let highlight_style = Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .add_modifier(Modifier::BOLD); - - let highlight_symbol = " "; - let highlight_width = UnicodeWidthStr::width(highlight_symbol); - let max_line_width = layout[1] - .width - .saturating_sub(highlight_width as u16) - .max(1) as usize; - - let active_model_id = app.selected_model(); - let annotated = app.annotated_models(); - - let mut items: Vec = Vec::new(); - for item in selector_items.iter() { - match item.kind() { - ModelSelectorItemKind::Header { - provider, - expanded, - status, - provider_type, - } => { - let mut spans = Vec::new(); - spans.push(status_icon(*status, theme)); - spans.push(Span::raw(" ")); - let display_name = ChatApp::provider_display_name(provider); - let header_spans = render_highlighted_text( - display_name.as_str(), - if search_active { - app.provider_search_highlight(provider) - } else { - None - }, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.mode_command)) - .add_modifier(Modifier::BOLD), - highlight_style, - ); - spans.extend(header_spans); - spans.push(Span::raw(" ")); - spans.push(provider_type_badge(*provider_type, theme)); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - if *expanded { "▼" } else { "▶" }, - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - )); - - let line = clip_line_to_width(Line::from(spans), max_line_width); - items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active))); - } - ModelSelectorItemKind::Scope { label, status, .. } => { - let (style, icon) = scope_status_style(*status, theme); - let line = clip_line_to_width( - Line::from(vec![ - Span::styled(icon, style), - Span::raw(" "), - Span::styled(label.clone(), style), - ]), - max_line_width, - ); - items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active))); - } - ModelSelectorItemKind::Model { model_index, .. } => { - let mut lines: Vec> = Vec::new(); - if let Some(model) = app.model_info_by_index(*model_index) { - let badges = model_badge_icons(model); - let detail = app.cached_model_detail(&model.id); - let annotated_model = annotated.get(*model_index); - let search_info = if search_active { - app.model_search_info(*model_index) - } else { - None - }; - let (title, metadata) = build_model_selector_lines( - theme, - model, - annotated_model, - &badges, - detail, - model.id == active_model_id, - SearchRenderContext { - info: search_info, - highlight_style, - }, - ); - lines.push(clip_line_to_width(title, max_line_width)); - if let Some(meta) = metadata { - lines.push(clip_line_to_width(meta, max_line_width)); - } - } else { - lines.push(clip_line_to_width( - Line::from(Span::styled( - " ", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.error)), - )), - max_line_width, - )); - } - items.push(ListItem::new(lines).style(Style::default().bg(palette.active))); - } - ModelSelectorItemKind::Empty { - message, status, .. - } => { - let (style, icon) = empty_status_style(*status, theme); - let msg = message - .as_ref() - .map(|msg| msg.as_str()) - .unwrap_or("(no models configured)"); - let mut spans = vec![Span::styled(icon, style), Span::raw(" ")]; - spans.push(Span::styled(format!(" {}", msg), style)); - let line = clip_line_to_width(Line::from(spans), max_line_width); - items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active))); - } - } - } - - let list = List::new(items) - .highlight_style( - Style::default() - .bg(crate::color_convert::to_ratatui_color(&theme.selection_bg)) - .fg(crate::color_convert::to_ratatui_color(&theme.selection_fg)) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(" ") - .style(Style::default().bg(palette.active).fg(palette.label)); - - let mut state = ListState::default(); - state.select(app.selected_model_item()); - frame.render_stateful_widget(list, layout[1], &mut state); - - let footer_text = if search_active { - "Enter: select · Space: toggle provider · Backspace: delete · Ctrl+U: clear" - } else { - "Enter: select · Space: toggle provider · Type to search · Esc: cancel" - }; - - let footer = Paragraph::new(Line::from(Span::styled( - footer_text, - Style::default().fg(palette.label), - ))) - .alignment(ratatui::layout::Alignment::Center) - .style(Style::default().bg(palette.highlight).fg(palette.label)); - frame.render_widget(footer, layout[2]); -} - -fn status_icon(status: ProviderStatus, theme: &owlen_core::Theme) -> Span<'static> { - let (symbol, color) = match status { - ProviderStatus::Available => ("✓", theme.info), - ProviderStatus::Unavailable => ("✗", theme.error), - ProviderStatus::RequiresSetup => ( - "⚙", - owlen_core::Color::Named(owlen_core::NamedColor::Yellow), - ), - }; - Span::styled( - symbol, - Style::default() - .fg(to_ratatui_color(&color)) - .add_modifier(Modifier::BOLD), - ) -} - -fn provider_type_badge(provider_type: ProviderType, theme: &owlen_core::Theme) -> Span<'static> { - let (label, color) = match provider_type { - ProviderType::Local => ("[Local]", theme.mode_normal), - ProviderType::Cloud => ("[Cloud]", theme.mode_help), - }; - Span::styled( - label, - Style::default() - .fg(to_ratatui_color(&color)) - .add_modifier(Modifier::BOLD), - ) -} - -fn scope_status_style( - status: ModelAvailabilityState, - theme: &owlen_core::Theme, -) -> (Style, &'static str) { - match status { - ModelAvailabilityState::Available => ( - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - "✓", - ), - ModelAvailabilityState::Unavailable => ( - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.error)) - .add_modifier(Modifier::BOLD), - "✗", - ), - ModelAvailabilityState::Unknown => ( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - "⚙", - ), - } -} - -fn empty_status_style( - status: Option, - theme: &owlen_core::Theme, -) -> (Style, &'static str) { - match status.unwrap_or(ModelAvailabilityState::Unknown) { - ModelAvailabilityState::Available => ( - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM), - "•", - ), - ModelAvailabilityState::Unavailable => ( - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.error)) - .add_modifier(Modifier::BOLD), - "✗", - ), - ModelAvailabilityState::Unknown => ( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - "⚙", - ), - } -} - -fn filter_badge(mode: FilterMode, theme: &owlen_core::Theme) -> Span<'static> { - let label = match mode { - FilterMode::All => return Span::raw(""), - FilterMode::LocalOnly => "Local", - FilterMode::CloudOnly => "Cloud", - FilterMode::Available => "Available", - }; - Span::styled( - format!("[{label}]"), - Style::default() - .fg(crate::color_convert::to_ratatui_color( - &theme.mode_provider_selection, - )) - .add_modifier(Modifier::BOLD), - ) -} - -fn render_highlighted_text( - text: &str, - highlight: Option<&HighlightMask>, - normal_style: Style, - highlight_style: Style, -) -> Vec> { - if text.is_empty() { - return Vec::new(); - } - - let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect(); - let mask = highlight.map(|mask| mask.bits()).unwrap_or(&[]); - - let mut spans: Vec> = Vec::new(); - let mut buffer = String::new(); - let mut current_highlight = false; - - for (idx, grapheme) in graphemes.iter().enumerate() { - let mark = mask.get(idx).copied().unwrap_or(false); - if idx == 0 { - current_highlight = mark; - } - if mark != current_highlight { - if !buffer.is_empty() { - let style = if current_highlight { - highlight_style - } else { - normal_style - }; - spans.push(Span::styled(buffer.clone(), style)); - buffer.clear(); - } - current_highlight = mark; - } - buffer.push_str(grapheme); - } - - if !buffer.is_empty() { - let style = if current_highlight { - highlight_style - } else { - normal_style - }; - spans.push(Span::styled(buffer, style)); - } - - if spans.is_empty() { - spans.push(Span::styled(text.to_string(), normal_style)); - } - - spans -} - -struct SearchRenderContext<'a> { - info: Option<&'a ModelSearchInfo>, - highlight_style: Style, -} - -fn build_model_selector_lines<'a>( - theme: &owlen_core::Theme, - model: &'a ModelInfo, - annotated: Option<&'a AnnotatedModelInfo>, - badges: &[&'static str], - detail: Option<&'a owlen_core::model::DetailedModelInfo>, - is_current: bool, - search: SearchRenderContext<'a>, -) -> (Line<'static>, Option>) { - let provider_type = annotated - .map(|info| info.model.provider.provider_type) - .unwrap_or_else(|| match ChatApp::model_scope_from_capabilities(model) { - ModelScope::Cloud => ProviderType::Cloud, - ModelScope::Local => ProviderType::Local, - ModelScope::Other(_) => { - if model.provider.to_ascii_lowercase().contains("cloud") { - ProviderType::Cloud - } else { - ProviderType::Local - } - } - }); - - let mut spans: Vec> = Vec::new(); - spans.push(Span::raw(" ")); - spans.push(provider_type_badge(provider_type, theme)); - spans.push(Span::raw(" ")); - - let name_style = Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.text)) - .add_modifier(Modifier::BOLD); - let display_name = ChatApp::display_name_for_model(model); - if !display_name.trim().is_empty() { - let name_spans = render_highlighted_text( - display_name.as_str(), - search.info.and_then(|info| info.name.as_ref()), - name_style, - search.highlight_style, - ); - spans.extend(name_spans); - } else { - let id_spans = render_highlighted_text( - model.id.as_str(), - search.info.and_then(|info| info.id.as_ref()), - name_style, - search.highlight_style, - ); - spans.extend(id_spans); - } - - if !badges.is_empty() { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - badges.join(" "), - Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)), - )); - } - - if is_current { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - "✓", - Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.info)) - .add_modifier(Modifier::BOLD), - )); - } - - let mut meta_tags: Vec = Vec::new(); - let mut seen_meta: HashSet = HashSet::new(); - let mut push_meta = |value: String| { - let trimmed = value.trim(); - if trimmed.is_empty() { - return; - } - let key = trimmed.to_ascii_lowercase(); - if seen_meta.insert(key) { - meta_tags.push(trimmed.to_string()); - } - }; - - let scope = ChatApp::model_scope_from_capabilities(model); - let scope_label = ChatApp::scope_display_name(&scope); - if !scope_label.eq_ignore_ascii_case("unknown") { - push_meta(scope_label.clone()); - } - - let provider_label = ChatApp::provider_display_name(&model.provider); - push_meta(format!("provider {}", provider_label)); - - if !display_name.trim().eq_ignore_ascii_case(model.id.trim()) { - push_meta(format!("id {}", model.id)); - } - - if let Some(detail) = detail { - if let Some(ctx) = detail.context_length { - push_meta(format!("max tokens {}", ctx)); - } else if let Some(ctx) = model.context_window { - push_meta(format!("max tokens {}", ctx)); - } - - if let Some(parameters) = detail - .parameter_size - .as_ref() - .or(detail.parameters.as_ref()) - && !parameters.trim().is_empty() - { - push_meta(parameters.trim().to_string()); - } - - if let Some(arch) = detail.architecture.as_deref() { - let trimmed = arch.trim(); - if !trimmed.is_empty() { - push_meta(format!("arch {}", trimmed)); - } - } else if let Some(family) = detail.family.as_deref() { - let trimmed = family.trim(); - if !trimmed.is_empty() { - push_meta(format!("family {}", trimmed)); - } - } else if !detail.families.is_empty() { - let families = detail - .families - .iter() - .map(|f| f.trim()) - .filter(|f| !f.is_empty()) - .take(2) - .collect::>() - .join("/"); - if !families.is_empty() { - push_meta(format!("family {}", families)); - } - } - - if let Some(embedding) = detail.embedding_length { - push_meta(format!("embedding {}", embedding)); - } - - if let Some(size) = detail.size { - push_meta(format_short_size(size)); - } - - if let Some(quant) = detail - .quantization - .as_ref() - .filter(|q| !q.trim().is_empty()) - { - push_meta(format!("quant {}", quant.trim())); - } - } else if let Some(ctx) = model.context_window { - push_meta(format!("max tokens {}", ctx)); - } - - let mut description_segment: Option<(String, Option)> = None; - if let Some(desc) = model.description.as_deref() { - let trimmed = desc.trim(); - if !trimmed.is_empty() { - let (display, retained, truncated) = ellipsize(trimmed, 80); - let highlight = search - .info - .and_then(|info| info.description.as_ref()) - .filter(|mask| mask.is_marked()) - .map(|mask| { - if truncated { - mask.truncated(retained) - } else { - mask.clone() - } - }); - description_segment = Some((display, highlight)); - } - } - - let metadata = if meta_tags.is_empty() && description_segment.is_none() { - None - } else { - let meta_style = Style::default() - .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) - .add_modifier(Modifier::DIM); - let mut segments: Vec> = Vec::new(); - segments.push(Span::styled(" ", meta_style)); - - let mut first = true; - for tag in meta_tags { - if !first { - segments.push(Span::styled(" • ", meta_style)); - } - segments.push(Span::styled(tag, meta_style)); - first = false; - } - - if let Some((text, highlight)) = description_segment { - if !first { - segments.push(Span::styled(" • ", meta_style)); - } - if let Some(mask) = highlight.as_ref() { - let desc_spans = render_highlighted_text( - text.as_str(), - Some(mask), - meta_style, - search.highlight_style, - ); - segments.extend(desc_spans); - } else { - segments.push(Span::styled(text, meta_style)); - } - } - - Some(Line::from(segments)) - }; - - (Line::from(spans), metadata) -} - -fn clip_line_to_width(line: Line<'_>, max_width: usize) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let mut used = 0usize; - let mut clipped: Vec> = Vec::new(); - - for span in line.spans { - if used >= max_width { - break; - } - let text = span.content.to_string(); - let span_width = UnicodeWidthStr::width(text.as_str()); - if used + span_width <= max_width { - if !text.is_empty() { - clipped.push(Span::styled(text, span.style)); - } - used += span_width; - } else { - let mut buf = String::new(); - for grapheme in span.content.as_ref().graphemes(true) { - let g_width = UnicodeWidthStr::width(grapheme); - if g_width == 0 { - buf.push_str(grapheme); - continue; - } - if used + g_width > max_width { - break; - } - buf.push_str(grapheme); - used += g_width; - } - if !buf.is_empty() { - clipped.push(Span::styled(buf, span.style)); - } - break; - } - } - - Line::from(clipped) -} - -fn ellipsize(text: &str, max_graphemes: usize) -> (String, usize, bool) { - let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect(); - if graphemes.len() <= max_graphemes { - return (text.to_string(), graphemes.len(), false); - } - - let keep = max_graphemes.saturating_sub(1).max(1); - let mut truncated = String::new(); - for grapheme in graphemes.iter().take(keep) { - truncated.push_str(grapheme); - } - truncated.push('…'); - (truncated, keep, true) -} - -fn model_badge_icons(model: &ModelInfo) -> Vec<&'static str> { - let mut badges = Vec::new(); - - if model.supports_tools { - badges.push("🔧"); - } - - if model_has_feature(model, &["think", "reason"]) { - badges.push("🧠"); - } - - if model_has_feature(model, &["vision", "multimodal", "image"]) { - badges.push("👁️"); - } - - if model_has_feature(model, &["audio", "speech", "voice"]) { - badges.push("🎧"); - } - - badges -} - -fn model_has_feature(model: &ModelInfo, keywords: &[&str]) -> bool { - let name_lower = model.name.to_ascii_lowercase(); - if keywords.iter().any(|kw| name_lower.contains(kw)) { - return true; - } - - if let Some(description) = &model.description { - let description_lower = description.to_ascii_lowercase(); - if keywords.iter().any(|kw| description_lower.contains(kw)) { - return true; - } - } - - if model.capabilities.iter().any(|cap| { - let lc = cap.to_ascii_lowercase(); - keywords.iter().any(|kw| lc.contains(kw)) - }) { - return true; - } - - keywords - .iter() - .any(|kw| model.provider.to_ascii_lowercase().contains(kw)) -} - -fn format_short_size(bytes: u64) -> String { - if bytes >= 1_000_000_000 { - format!("{:.1} GB", bytes as f64 / 1_000_000_000_f64) - } else if bytes >= 1_000_000 { - format!("{:.1} MB", bytes as f64 / 1_000_000_f64) - } else if bytes >= 1_000 { - format!("{:.1} KB", bytes as f64 / 1_000_f64) - } else { - format!("{} B", bytes) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use owlen_core::types::ModelInfo; - - fn model_with(capabilities: Vec<&str>, description: Option<&str>) -> ModelInfo { - ModelInfo { - id: "model".into(), - name: "model".into(), - description: description.map(|s| s.to_string()), - provider: "test".into(), - context_window: None, - capabilities: capabilities.into_iter().map(|s| s.to_string()).collect(), - supports_tools: false, - } - } - - #[test] - fn model_badges_recognize_thinking_capability() { - let model = model_with(vec!["think"], None); - assert!(model_badge_icons(&model).contains(&"🧠")); - } - - #[test] - fn model_badges_detect_tool_support() { - let mut model = model_with(vec![], None); - model.supports_tools = true; - let icons = model_badge_icons(&model); - assert!(icons.contains(&"🔧")); - } - - #[test] - fn model_badges_detect_vision_capability() { - let model = model_with(vec![], Some("Supports vision tasks")); - let icons = model_badge_icons(&model); - assert!(icons.contains(&"👁️")); - } - - #[test] - fn model_badges_detect_audio_capability() { - let model = model_with(vec!["audio"], None); - let icons = model_badge_icons(&model); - assert!(icons.contains(&"🎧")); - } -} diff --git a/crates/owlen-tui/tests/agent_flow_ui.rs b/crates/owlen-tui/tests/agent_flow_ui.rs deleted file mode 100644 index 46624d6..0000000 --- a/crates/owlen-tui/tests/agent_flow_ui.rs +++ /dev/null @@ -1,164 +0,0 @@ -use std::{any::Any, sync::Arc}; - -use async_trait::async_trait; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use futures_util::stream; -use owlen_core::{ - Config, Mode, Provider, - config::McpMode, - session::SessionController, - storage::StorageManager, - types::{ChatResponse, Message, Role, ToolCall}, - ui::{NoOpUiController, UiController}, -}; -use owlen_tui::ChatApp; -use owlen_tui::app::UiRuntime; -use owlen_tui::events::Event; -use tempfile::tempdir; -use tokio::sync::mpsc; - -struct StubProvider; - -#[async_trait] -impl Provider for StubProvider { - fn name(&self) -> &str { - "stub-provider" - } - - async fn list_models(&self) -> owlen_core::Result> { - Ok(vec![owlen_core::types::ModelInfo { - id: "stub-model".into(), - name: "Stub Model".into(), - description: Some("Stub model for testing".into()), - provider: self.name().into(), - context_window: Some(4096), - capabilities: vec!["chat".into()], - supports_tools: true, - }]) - } - - async fn send_prompt( - &self, - _request: owlen_core::types::ChatRequest, - ) -> owlen_core::Result { - Ok(ChatResponse { - message: Message::assistant("stub response".to_string()), - usage: None, - is_streaming: false, - is_final: true, - }) - } - - async fn stream_prompt( - &self, - _request: owlen_core::types::ChatRequest, - ) -> owlen_core::Result { - Ok(Box::pin(stream::empty())) - } - - async fn health_check(&self) -> owlen_core::Result<()> { - Ok(()) - } - - fn as_any(&self) -> &(dyn Any + Send + Sync) { - self - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn denied_consent_appends_apology_message() { - let temp_dir = tempdir().expect("temp dir"); - let storage = Arc::new( - StorageManager::with_database_path(temp_dir.path().join("owlen-tui-tests.db")) - .await - .expect("storage"), - ); - - let mut config = Config::default(); - config.privacy.encrypt_local_data = false; - config.general.default_model = Some("stub-model".into()); - config.mcp.mode = McpMode::LocalOnly; - config - .refresh_mcp_servers(None) - .expect("refresh MCP servers"); - - let provider: Arc = Arc::new(StubProvider); - let ui: Arc = Arc::new(NoOpUiController); - let (event_tx, controller_event_rx) = mpsc::unbounded_channel(); - - // Pre-populate a pending consent request before handing the controller to the TUI. - let mut session = SessionController::new( - Arc::clone(&provider), - config, - Arc::clone(&storage), - Arc::clone(&ui), - true, - Some(event_tx.clone()), - ) - .await - .expect("session controller"); - - session - .set_operating_mode(Mode::Code) - .await - .expect("code mode"); - - let tool_call = ToolCall { - id: "call-1".to_string(), - name: "resources_delete".to_string(), - arguments: serde_json::json!({"path": "/tmp/example.txt"}), - }; - - let message_id = session - .conversation_mut() - .push_assistant_message("Preparing to modify files."); - session - .conversation_mut() - .set_tool_calls_on_message(message_id, vec![tool_call]) - .expect("tool calls"); - - let advertised_calls = session - .check_streaming_tool_calls(message_id) - .expect("queued consent"); - assert_eq!(advertised_calls.len(), 1); - - let (mut app, mut session_rx) = ChatApp::new(session, controller_event_rx) - .await - .expect("chat app"); - // Session events are not used in this test. - session_rx.close(); - - // Process the controller event emitted by check_streaming_tool_calls. - UiRuntime::poll_controller_events(&mut app).expect("poll controller events"); - assert!(app.has_pending_consent()); - - let consent_state = app - .consent_dialog() - .expect("consent dialog should be visible") - .clone(); - assert_eq!(consent_state.tool_name, "resources_delete"); - - // Simulate the user pressing "4" to deny consent. - let deny_key = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE); - UiRuntime::handle_ui_event(&mut app, Event::Key(deny_key)) - .await - .expect("handle deny key"); - - assert!(!app.has_pending_consent()); - assert!( - app.status_message() - .to_lowercase() - .contains("consent denied") - ); - - let conversation = app.conversation(); - let last_message = conversation.messages.last().expect("last message"); - assert_eq!(last_message.role, Role::Assistant); - assert!( - last_message - .content - .to_lowercase() - .contains("consent was denied"), - "assistant should acknowledge the denied consent" - ); -} diff --git a/crates/owlen-tui/tests/chat_snapshots.rs b/crates/owlen-tui/tests/chat_snapshots.rs deleted file mode 100644 index a5a7256..0000000 --- a/crates/owlen-tui/tests/chat_snapshots.rs +++ /dev/null @@ -1,322 +0,0 @@ -mod common; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use insta::{assert_snapshot, with_settings}; -use owlen_core::types::{Message, ToolCall}; -use owlen_tui::ChatApp; -use owlen_tui::events::Event; -use owlen_tui::ui::render_chat; -use ratatui::{Terminal, backend::TestBackend}; - -use common::build_chat_app; - -async fn send_key(app: &mut ChatApp, code: KeyCode, modifiers: KeyModifiers) { - app.handle_event(Event::Key(KeyEvent::new(code, modifiers))) - .await - .expect("send key event"); -} - -async fn type_text(app: &mut ChatApp, text: &str) { - for ch in text.chars() { - send_key(app, KeyCode::Char(ch), KeyModifiers::NONE).await; - } -} - -fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String { - let mut output = String::new(); - - for y in 0..buffer.area.height { - output.push('"'); - for x in 0..buffer.area.width { - output.push_str(buffer[(x, y)].symbol()); - } - output.push('"'); - output.push('\n'); - } - - output -} - -fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String { - let backend = TestBackend::new(width, height); - let mut terminal = Terminal::new(backend).expect("terminal"); - - terminal - .draw(|frame| render_chat(frame, app)) - .expect("render chat"); - - let buffer = terminal.backend().buffer(); - buffer_to_string(buffer) -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_chat_idle_snapshot() { - let mut app_80 = build_chat_app(|_| {}, |_| {}).await; - with_settings!({ snapshot_suffix => "80x35" }, { - let snapshot = render_snapshot(&mut app_80, 80, 35); - assert_snapshot!("chat_idle_snapshot", snapshot); - }); - - let mut app_100 = build_chat_app(|_| {}, |_| {}).await; - with_settings!({ snapshot_suffix => "100x35" }, { - let snapshot = render_snapshot(&mut app_100, 100, 35); - assert_snapshot!("chat_idle_snapshot", snapshot); - }); - - let mut app_140 = build_chat_app(|_| {}, |_| {}).await; - with_settings!({ snapshot_suffix => "140x35" }, { - let snapshot = render_snapshot(&mut app_140, 140, 35); - assert_snapshot!("chat_idle_snapshot", snapshot); - }); -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_chat_tool_call_snapshot() { - let mut app = build_chat_app( - |_| {}, - |session| { - let conversation = session.conversation_mut(); - conversation.push_user_message("What happened in the Rust ecosystem today?"); - - let stream_id = conversation.start_streaming_response(); - conversation - .set_stream_placeholder(stream_id, "Consulting the knowledge base…") - .expect("placeholder"); - - let tool_call = ToolCall { - id: "call-search-1".into(), - name: "web_search".into(), - arguments: serde_json::json!({ "query": "Rust language news" }), - }; - conversation - .set_tool_calls_on_message(stream_id, vec![tool_call.clone()]) - .expect("tool call metadata"); - conversation - .append_stream_chunk(stream_id, "Found multiple articles…", false) - .expect("stream chunk"); - - let tool_message = Message::tool( - tool_call.id.clone(), - "Rust 1.85 released with generics cleanups and faster async compilation." - .to_string(), - ); - conversation.push_message(tool_message); - - let assistant_summary = Message::assistant( - "Summarising the latest Rust release and the async runtime updates.".into(), - ); - conversation.push_message(assistant_summary); - }, - ) - .await; - - // Surface quota toast to exercise header/status rendering. - app.push_toast( - owlen_tui::toast::ToastLevel::Warning, - "Cloud usage is at 82% of the hourly quota.", - ); - - with_settings!({ - snapshot_suffix => "80x24" - }, { - let snapshot = render_snapshot(&mut app, 80, 24); - assert_snapshot!("chat_tool_call_snapshot", snapshot); - }); -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_chat_idle_no_animation_snapshot() { - let mut app = build_chat_app( - |cfg| { - cfg.ui.animations.micro = false; - }, - |_| {}, - ) - .await; - - with_settings!({ snapshot_suffix => "no-anim-100x35" }, { - let snapshot = render_snapshot(&mut app, 100, 35); - assert_snapshot!("chat_idle_snapshot_no_anim", snapshot); - }); -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_command_palette_focus_snapshot() { - let mut app = build_chat_app(|_| {}, |_| {}).await; - - app.handle_event(Event::Key(KeyEvent::new( - KeyCode::Char(':'), - KeyModifiers::NONE, - ))) - .await - .expect("enter command mode"); - - for ch in ['f', 'o', 'c', 'u', 's'] { - app.handle_event(Event::Key(KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::NONE, - ))) - .await - .expect("type query"); - } - - // Highlight the second suggestion (typically the model picker preview). - app.handle_event(Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))) - .await - .expect("move selection"); - - with_settings!({ snapshot_suffix => "80x20" }, { - let snapshot = render_snapshot(&mut app, 80, 20); - assert_snapshot!("command_palette_focus", snapshot); - }); -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_guidance_onboarding_snapshot() { - let mut app = build_chat_app( - |cfg| { - cfg.ui.show_onboarding = true; - cfg.ui.guidance.coach_marks_complete = false; - }, - |_| {}, - ) - .await; - - with_settings!({ snapshot_suffix => "step1-80x24" }, { - let snapshot = render_snapshot(&mut app, 80, 24); - assert_snapshot!("guidance_onboarding", snapshot); - }); - - app.handle_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))) - .await - .expect("advance onboarding to step 2"); - - with_settings!({ snapshot_suffix => "step2-100x24" }, { - let snapshot = render_snapshot(&mut app, 100, 24); - assert_snapshot!("guidance_onboarding", snapshot); - }); -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_guidance_cheatsheet_snapshot() { - let mut app = build_chat_app(|cfg| cfg.ui.guidance.coach_marks_complete = true, |_| {}).await; - - app.handle_event(Event::Key(KeyEvent::new( - KeyCode::Char('?'), - KeyModifiers::NONE, - ))) - .await - .expect("open guidance overlay"); - - with_settings!({ snapshot_suffix => "tab1-100x24" }, { - let snapshot = render_snapshot(&mut app, 100, 24); - assert_snapshot!("guidance_cheatsheet", snapshot); - }); - - app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))) - .await - .expect("advance guidance tab"); - - with_settings!({ snapshot_suffix => "tab2-100x24" }, { - let snapshot = render_snapshot(&mut app, 100, 24); - assert_snapshot!("guidance_cheatsheet", snapshot); - }); -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_mode_states_snapshots() { - let mut normal = build_chat_app(|_| {}, |_| {}).await; - with_settings!({ snapshot_suffix => "normal-90x28" }, { - let snapshot = render_snapshot(&mut normal, 90, 28); - assert_snapshot!("mode_states", snapshot); - }); - - let mut editing = build_chat_app(|_| {}, |_| {}).await; - send_key(&mut editing, KeyCode::Char('i'), KeyModifiers::NONE).await; - type_text(&mut editing, "Typing in editing mode").await; - with_settings!({ snapshot_suffix => "editing-90x28" }, { - let snapshot = render_snapshot(&mut editing, 90, 28); - assert_snapshot!("mode_states", snapshot); - }); - - let mut visual = build_chat_app( - |_| {}, - |session| { - let conversation = session.conversation_mut(); - conversation.push_user_message("Render visual selection across multiple lines."); - conversation.push_message(Message::assistant( - "Assistant reply for visual mode highlighting.".into(), - )); - }, - ) - .await; - send_key(&mut visual, KeyCode::Char('v'), KeyModifiers::NONE).await; - send_key(&mut visual, KeyCode::Char('j'), KeyModifiers::NONE).await; - send_key(&mut visual, KeyCode::Char('j'), KeyModifiers::NONE).await; - with_settings!({ snapshot_suffix => "visual-90x28" }, { - let snapshot = render_snapshot(&mut visual, 90, 28); - assert_snapshot!("mode_states", snapshot); - }); - - let mut command = build_chat_app(|_| {}, |_| {}).await; - send_key(&mut command, KeyCode::Char(':'), KeyModifiers::NONE).await; - type_text(&mut command, "session save").await; - with_settings!({ snapshot_suffix => "command-90x28" }, { - let snapshot = render_snapshot(&mut command, 90, 28); - assert_snapshot!("mode_states", snapshot); - }); -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_emacs_profile_snapshot() { - let mut app = build_chat_app( - |cfg| { - cfg.ui.keymap_profile = Some("emacs".into()); - cfg.ui.guidance.coach_marks_complete = true; - }, - |_| {}, - ) - .await; - - send_key(&mut app, KeyCode::Char(':'), KeyModifiers::NONE).await; - type_text(&mut app, "help").await; - - with_settings!({ snapshot_suffix => "emacs-110x30" }, { - let snapshot = render_snapshot(&mut app, 110, 30); - assert_snapshot!("emacs_profile", snapshot); - }); -} - -#[tokio::test(flavor = "multi_thread")] -async fn render_accessibility_snapshots() { - let mut high_contrast = build_chat_app( - |cfg| { - cfg.ui.accessibility.high_contrast = true; - cfg.ui.guidance.coach_marks_complete = true; - }, - |_| {}, - ) - .await; - - with_settings!({ snapshot_suffix => "high-contrast-100x32" }, { - let snapshot = render_snapshot(&mut high_contrast, 100, 32); - assert_snapshot!("accessibility_modes", snapshot); - }); - - let mut reduced_chrome = build_chat_app( - |cfg| { - cfg.ui.accessibility.reduced_chrome = true; - cfg.ui.guidance.coach_marks_complete = true; - }, - |_| {}, - ) - .await; - - with_settings!({ snapshot_suffix => "reduced-chrome-100x32" }, { - let snapshot = render_snapshot(&mut reduced_chrome, 100, 32); - assert_snapshot!("accessibility_modes", snapshot); - }); -} diff --git a/crates/owlen-tui/tests/common/mod.rs b/crates/owlen-tui/tests/common/mod.rs deleted file mode 100644 index bfc4a42..0000000 --- a/crates/owlen-tui/tests/common/mod.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use owlen_core::{ - Config, Mode, Provider, - session::SessionController, - storage::StorageManager, - types::Message, - ui::{NoOpUiController, UiController}, -}; -use owlen_tui::ChatApp; -use tempfile::tempdir; -use tokio::sync::mpsc; - -struct StubProvider; - -#[async_trait] -impl Provider for StubProvider { - fn name(&self) -> &str { - "stub-provider" - } - - async fn list_models(&self) -> owlen_core::Result> { - Ok(vec![owlen_core::types::ModelInfo { - id: "stub-model".into(), - name: "Stub Model".into(), - description: Some("Stub model for golden snapshot tests".into()), - provider: self.name().into(), - context_window: Some(8_192), - capabilities: vec!["chat".into(), "tool-use".into()], - supports_tools: true, - }]) - } - - async fn send_prompt( - &self, - _request: owlen_core::types::ChatRequest, - ) -> owlen_core::Result { - Ok(owlen_core::types::ChatResponse { - message: Message::assistant("stub completion".into()), - usage: None, - is_streaming: false, - is_final: true, - }) - } - - async fn stream_prompt( - &self, - _request: owlen_core::types::ChatRequest, - ) -> owlen_core::Result { - Ok(Box::pin(futures_util::stream::empty())) - } - - async fn health_check(&self) -> owlen_core::Result<()> { - Ok(()) - } - - fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) { - self - } -} - -pub async fn build_chat_app(configure_config: C, configure_session: F) -> ChatApp -where - C: FnOnce(&mut Config), - F: FnOnce(&mut SessionController), -{ - let temp_dir = tempdir().expect("temp dir"); - let storage = StorageManager::with_database_path(temp_dir.path().join("owlen-tui-tests.db")) - .await - .expect("storage"); - let storage = Arc::new(storage); - - let mut config = Config::default(); - config.general.default_model = Some("stub-model".into()); - config.general.enable_streaming = true; - config.privacy.encrypt_local_data = false; - config.privacy.require_consent_per_session = false; - config.ui.show_onboarding = false; - config.ui.show_timestamps = false; - configure_config(&mut config); - let provider: Arc = Arc::new(StubProvider); - let ui: Arc = Arc::new(NoOpUiController); - let (event_tx, controller_event_rx) = mpsc::unbounded_channel(); - - let mut session = SessionController::new( - Arc::clone(&provider), - config, - Arc::clone(&storage), - ui, - true, - Some(event_tx), - ) - .await - .expect("session controller"); - - session - .set_operating_mode(Mode::Chat) - .await - .expect("chat mode"); - - configure_session(&mut session); - - let (app, mut session_rx) = ChatApp::new(session, controller_event_rx) - .await - .expect("chat app"); - session_rx.close(); - - app -} diff --git a/crates/owlen-tui/tests/generation_tests.rs b/crates/owlen-tui/tests/generation_tests.rs deleted file mode 100644 index 25a0353..0000000 --- a/crates/owlen-tui/tests/generation_tests.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::Result; -use async_trait::async_trait; -use futures_util::stream; -use owlen_core::provider::{ - GenerateChunk, GenerateRequest, GenerateStream, ModelInfo, ModelProvider, ProviderMetadata, - ProviderStatus, ProviderType, -}; -use owlen_core::state::AppState; -use owlen_tui::app::{self, App, MessageState, messages::AppMessage}; -use tokio::sync::mpsc; -use tokio::task::{JoinHandle, yield_now}; -use tokio::time::advance; -use uuid::Uuid; - -#[derive(Clone)] -struct StatusProvider { - metadata: ProviderMetadata, - status: Arc>, - chunks: Arc>, -} - -impl StatusProvider { - fn new(status: ProviderStatus, chunks: Vec) -> Self { - Self { - metadata: ProviderMetadata::new("stub", "Stub", ProviderType::Local, false), - status: Arc::new(Mutex::new(status)), - chunks: Arc::new(chunks), - } - } - - fn set_status(&self, status: ProviderStatus) { - *self.status.lock().unwrap() = status; - } -} - -#[async_trait] -impl ModelProvider for StatusProvider { - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - - async fn health_check(&self) -> Result { - Ok(*self.status.lock().unwrap()) - } - - async fn list_models(&self) -> Result, owlen_core::Error> { - Ok(vec![]) - } - - async fn generate_stream( - &self, - _request: GenerateRequest, - ) -> Result { - let items = Arc::clone(&self.chunks); - let stream_items = items.as_ref().clone(); - Ok(Box::pin(stream::iter(stream_items.into_iter().map(Ok)))) - } -} - -#[derive(Default)] -struct RecordingState { - started: bool, - appended: bool, - completed: bool, - failed: bool, - refreshed: bool, - updated: bool, - provider_status: Option, -} - -impl MessageState for RecordingState { - fn start_generation( - &mut self, - _request_id: Uuid, - _provider_id: &str, - _request: &GenerateRequest, - ) -> AppState { - self.started = true; - AppState::Running - } - - fn append_chunk(&mut self, _request_id: Uuid, _chunk: &GenerateChunk) -> AppState { - self.appended = true; - AppState::Running - } - - fn generation_complete(&mut self, _request_id: Uuid) -> AppState { - self.completed = true; - AppState::Running - } - - fn generation_failed(&mut self, _request_id: Option, _message: &str) -> AppState { - self.failed = true; - AppState::Running - } - - fn refresh_model_list(&mut self) -> AppState { - self.refreshed = true; - AppState::Running - } - - fn update_model_list(&mut self) -> AppState { - self.updated = true; - AppState::Running - } - - fn update_provider_status(&mut self, _provider_id: &str, status: ProviderStatus) -> AppState { - self.provider_status = Some(status); - AppState::Running - } -} - -#[tokio::test] -async fn start_and_abort_generation_manage_active_state() { - let manager = Arc::new(owlen_core::provider::ProviderManager::default()); - let provider = StatusProvider::new( - ProviderStatus::Available, - vec![ - GenerateChunk::from_text("hello"), - GenerateChunk::final_chunk(), - ], - ); - manager.register_provider(Arc::new(provider.clone())).await; - let mut app = App::new(Arc::clone(&manager)); - - let request_id = app - .start_generation("stub", GenerateRequest::new("stub-model")) - .expect("start generation"); - assert!(app.has_active_generation()); - assert_ne!(request_id, Uuid::nil()); - - app.abort_active_generation(); - assert!(!app.has_active_generation()); -} - -#[test] -fn handle_message_dispatches_variants() { - let manager = Arc::new(owlen_core::provider::ProviderManager::default()); - let mut app = App::new(Arc::clone(&manager)); - let mut state = RecordingState::default(); - let request_id = Uuid::new_v4(); - - let _ = app.handle_message( - &mut state, - AppMessage::GenerateStart { - request_id, - provider_id: "stub".into(), - request: GenerateRequest::new("stub"), - }, - ); - let _ = app.handle_message( - &mut state, - AppMessage::GenerateChunk { - request_id, - chunk: GenerateChunk::from_text("chunk"), - }, - ); - let _ = app.handle_message(&mut state, AppMessage::GenerateComplete { request_id }); - let _ = app.handle_message( - &mut state, - AppMessage::GenerateError { - request_id: Some(request_id), - message: "error".into(), - }, - ); - let _ = app.handle_message(&mut state, AppMessage::ModelsRefresh); - let _ = app.handle_message(&mut state, AppMessage::ModelsUpdated); - let _ = app.handle_message( - &mut state, - AppMessage::ProviderStatus { - provider_id: "stub".into(), - status: ProviderStatus::Available, - }, - ); - - assert!(state.started); - assert!(state.appended); - assert!(state.completed); - assert!(state.failed); - assert!(state.refreshed); - assert!(state.updated); - assert!(matches!( - state.provider_status, - Some(ProviderStatus::Available) - )); -} - -#[tokio::test(start_paused = true)] -async fn background_worker_emits_status_changes() { - let manager = Arc::new(owlen_core::provider::ProviderManager::default()); - let provider = StatusProvider::new( - ProviderStatus::Unavailable, - vec![GenerateChunk::final_chunk()], - ); - manager.register_provider(Arc::new(provider.clone())).await; - - let (tx, mut rx) = mpsc::channel(256); - let worker: JoinHandle<()> = tokio::spawn(app::background_worker(Arc::clone(&manager), tx)); - - provider.set_status(ProviderStatus::Available); - advance(Duration::from_secs(31)).await; - yield_now().await; - - if let Some(AppMessage::ProviderStatus { status, .. }) = rx.recv().await { - assert!(matches!(status, ProviderStatus::Available)); - } else { - panic!("expected provider status update"); - } - - worker.abort(); - let _ = worker.await; - yield_now().await; -} diff --git a/crates/owlen-tui/tests/guidance_persistence.rs b/crates/owlen-tui/tests/guidance_persistence.rs deleted file mode 100644 index fe9fc4a..0000000 --- a/crates/owlen-tui/tests/guidance_persistence.rs +++ /dev/null @@ -1,84 +0,0 @@ -mod common; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use owlen_core::config::Config; -use owlen_tui::events::Event; -use tempfile::tempdir; - -use common::build_chat_app; - -struct XdgConfigGuard { - previous: Option, -} - -impl XdgConfigGuard { - fn set(path: &std::path::Path) -> Self { - let previous = std::env::var_os("XDG_CONFIG_HOME"); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", path); - } - Self { previous } - } -} - -impl Drop for XdgConfigGuard { - fn drop(&mut self) { - if let Some(prev) = self.previous.take() { - unsafe { - std::env::set_var("XDG_CONFIG_HOME", prev); - } - } else { - unsafe { - std::env::remove_var("XDG_CONFIG_HOME"); - } - } - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn onboarding_completion_persists_config() { - let temp_dir = tempdir().expect("temp config dir"); - let _guard = XdgConfigGuard::set(temp_dir.path()); - - let mut app = build_chat_app( - |cfg| { - cfg.ui.show_onboarding = true; - cfg.ui.guidance.coach_marks_complete = false; - }, - |_| {}, - ) - .await; - - for _ in 0..3 { - app.handle_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))) - .await - .expect("advance onboarding"); - } - - assert!( - app.coach_marks_complete(), - "coach marks flag should be recorded in memory" - ); - - drop(app); - - let persisted_path = temp_dir.path().join("owlen").join("config.toml"); - assert!( - persisted_path.exists(), - "expected persisted config at {:?}", - persisted_path - ); - - let persisted = Config::load(Some(&persisted_path)).expect("load persisted config snapshot"); - assert!( - !persisted.ui.show_onboarding, - "onboarding flag should be false in persisted config" - ); - assert!( - persisted.ui.guidance.coach_marks_complete, - "coach marks flag should be true in persisted config" - ); -} diff --git a/crates/owlen-tui/tests/message_tests.rs b/crates/owlen-tui/tests/message_tests.rs deleted file mode 100644 index 8216e2d..0000000 --- a/crates/owlen-tui/tests/message_tests.rs +++ /dev/null @@ -1,97 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; -use owlen_core::provider::{GenerateChunk, GenerateRequest, ProviderStatus}; -use owlen_tui::app::messages::AppMessage; -use uuid::Uuid; - -#[test] -fn message_variants_roundtrip_their_data() { - let request = GenerateRequest::new("demo-model"); - let request_id = Uuid::new_v4(); - let key_event = KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }; - - let messages = vec![ - AppMessage::KeyPress(key_event), - AppMessage::Resize { - width: 120, - height: 40, - }, - AppMessage::Tick, - AppMessage::GenerateStart { - request_id, - provider_id: "mock".into(), - request: request.clone(), - }, - AppMessage::GenerateChunk { - request_id, - chunk: GenerateChunk::from_text("hi"), - }, - AppMessage::GenerateComplete { request_id }, - AppMessage::GenerateError { - request_id: Some(request_id), - message: "oops".into(), - }, - AppMessage::ModelsRefresh, - AppMessage::ModelsUpdated, - AppMessage::ProviderStatus { - provider_id: "mock".into(), - status: ProviderStatus::Available, - }, - ]; - - for message in messages { - match message { - AppMessage::KeyPress(event) => { - assert_eq!(event.code, KeyCode::Char('a')); - assert!(event.modifiers.contains(KeyModifiers::CONTROL)); - } - AppMessage::Resize { width, height } => { - assert_eq!(width, 120); - assert_eq!(height, 40); - } - AppMessage::Tick => {} - AppMessage::GenerateStart { - request_id: id, - provider_id, - request, - } => { - assert_eq!(id, request_id); - assert_eq!(provider_id, "mock"); - assert_eq!(request.model, "demo-model"); - } - AppMessage::GenerateChunk { - request_id: id, - chunk, - } => { - assert_eq!(id, request_id); - assert_eq!(chunk.text.as_deref(), Some("hi")); - } - AppMessage::GenerateComplete { request_id: id } => { - assert_eq!(id, request_id); - } - AppMessage::GenerateError { - request_id: Some(id), - message, - } => { - assert_eq!(id, request_id); - assert_eq!(message, "oops"); - } - AppMessage::ModelsRefresh => {} - AppMessage::ModelsUpdated => {} - AppMessage::ProviderStatus { - provider_id, - status, - } => { - assert_eq!(provider_id, "mock"); - assert!(matches!(status, ProviderStatus::Available)); - } - AppMessage::GenerateError { - request_id: None, .. - } => panic!("missing request id"), - } - } -} diff --git a/crates/owlen-tui/tests/queue_tests.rs b/crates/owlen-tui/tests/queue_tests.rs deleted file mode 100644 index a05a644..0000000 --- a/crates/owlen-tui/tests/queue_tests.rs +++ /dev/null @@ -1,116 +0,0 @@ -mod common; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use insta::{assert_snapshot, with_settings}; -use owlen_tui::ChatApp; -use owlen_tui::events::Event; -use owlen_tui::ui::render_chat; -use ratatui::{Terminal, backend::TestBackend}; -use std::time::Duration; -use tokio::time::advance; - -use common::build_chat_app; - -fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String { - let mut output = String::new(); - for y in 0..buffer.area.height { - output.push('"'); - for x in 0..buffer.area.width { - output.push_str(buffer[(x, y)].symbol()); - } - output.push('"'); - output.push('\n'); - } - output -} - -fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String { - let backend = TestBackend::new(width, height); - let mut terminal = Terminal::new(backend).expect("terminal"); - terminal - .draw(|frame| render_chat(frame, app)) - .expect("render chat"); - let buffer = terminal.backend().buffer(); - buffer_to_string(buffer) -} - -async fn send_key(app: &mut ChatApp, code: KeyCode, modifiers: KeyModifiers) { - app.handle_event(Event::Key(KeyEvent::new(code, modifiers))) - .await - .unwrap(); -} - -async fn type_text(app: &mut ChatApp, text: &str) { - for ch in text.chars() { - send_key(app, KeyCode::Char(ch), KeyModifiers::NONE).await; - } -} - -#[tokio::test(start_paused = true)] -async fn render_queued_submission_snapshot() { - let mut app = build_chat_app(|_| {}, |_| {}).await; - - // Enter insert mode - send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await; - - // Type and send first message - type_text(&mut app, "first message").await; - send_key(&mut app, KeyCode::Enter, KeyModifiers::NONE).await; - - // First message is "in-flight". - // Now, type and send a second message. - send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await; - type_text(&mut app, "second message").await; - send_key(&mut app, KeyCode::Enter, KeyModifiers::NONE).await; - - // The second message should be queued. - with_settings!({ snapshot_suffix => "queued-80x24" }, { - let snapshot = render_snapshot(&mut app, 80, 24); - assert_snapshot!("queued_submission", snapshot); - }); - - // Now, let the first message complete. - // The stub provider responds immediately, but the processing is async. - // We need to advance time to let the background task run. - advance(Duration::from_secs(1)).await; - // We also need to process the events from the background task. - app.handle_event(Event::Tick).await.unwrap(); - app.handle_event(Event::Tick).await.unwrap(); - - // The second message should now be processing. - with_settings!({ snapshot_suffix => "processing-second-80x24" }, { - let snapshot = render_snapshot(&mut app, 80, 24); - assert_snapshot!("processing_second_submission", snapshot); - }); -} - -#[tokio::test(start_paused = true)] -async fn test_cancellation_of_queued_request() { - let mut app = build_chat_app(|_| {}, |_| {}).await; - - // Enter insert mode - send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await; - - // Type and send first message - type_text(&mut app, "first message").await; - send_key(&mut app, KeyCode::Enter, KeyModifiers::NONE).await; - - // First message is "in-flight". - // Now, type and send a second message. - send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await; - type_text(&mut app, "second message").await; - send_key(&mut app, KeyCode::Enter, KeyModifiers::NONE).await; - - // Cancel the active generation - send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await; - - // The first request should be cancelled, and the second one should start. - advance(Duration::from_secs(1)).await; - app.handle_event(Event::Tick).await.unwrap(); - app.handle_event(Event::Tick).await.unwrap(); - - with_settings!({ snapshot_suffix => "cancelled-80x24" }, { - let snapshot = render_snapshot(&mut app, 80, 24); - assert_snapshot!("cancellation_starts_next", snapshot); - }); -} diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__accessibility_modes@high-contrast-100x32.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__accessibility_modes@high-contrast-100x32.snap deleted file mode 100644 index ec48a66..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__accessibility_modes@high-contrast-100x32.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model · Accessibil " -" " -" Context metrics not available Cloud usage pending " -" " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" " -" No messages yet. Press 'i' to start typing. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" Input Press i to start typing · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__accessibility_modes@reduced-chrome-100x32.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__accessibility_modes@reduced-chrome-100x32.snap deleted file mode 100644 index 00f7505..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__accessibility_modes@reduced-chrome-100x32.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model · Accessibili " -" " -" Context metrics not available Cloud usage pending " -" " -" Legend · Normal <60% · Warning 60–85% · Critical >85% · Modes RC " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" No messages yet. Press 'i' to start typing. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" Input Press i to start typing · Ctrl+5 focus " -" Press 'i' to start typing " -" " -" System/Status " -" Icons: Nerd (auto) " -" " -" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@100x35.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@100x35.snap deleted file mode 100644 index 230c123..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@100x35.snap +++ /dev/null @@ -1,39 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" " -" No messages yet. Press 'i' to start typing. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" Input Press i to start typing · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@140x35.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@140x35.snap deleted file mode 100644 index feb2550..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@140x35.snap +++ /dev/null @@ -1,39 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" " -" No messages yet. Press 'i' to start typing. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" Input Press i to start typing · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " -" " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@80x35.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@80x35.snap deleted file mode 100644 index 6883fb1..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@80x35.snap +++ /dev/null @@ -1,39 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" Context metrics not available Cloud usage pending " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" " -" No messages yet. Press 'i' to start typing. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" Input Press i to start typing · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tu " -" Ctrl+5 Icons: Nerd (auto) i " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot_no_anim@no-anim-100x35.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot_no_anim@no-anim-100x35.snap deleted file mode 100644 index 230c123..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot_no_anim@no-anim-100x35.snap +++ /dev/null @@ -1,39 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" " -" No messages yet. Press 'i' to start typing. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" Input Press i to start typing · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_tool_call_snapshot@80x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_tool_call_snapshot@80x24.snap deleted file mode 100644 index f02f575..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_tool_call_snapshot@80x24.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" Context metrics not available Cloud usage pending " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" ┌──────────────────────────────────────────────────┐ " -" │ Found multiple arti│WARN ⚠ Cloud usage is at 82% of the hourly quota.│ " -" └──────────────────────│████████████████████████ 2s │ " -" ┌ 🔧 Tool [Result: cal└──────────────────────────────────────────────────┘ " -" │ Rust 1.85 released with generics cleanups and faster async compila │ " -" │ tion. │ " -" └────────────────────────────────────────────────────────────────────────┘ " -" ┌ 🤖 Assistant ──────────────────────────────────────────────────────────┐ " -" │ Summarising the latest Rust release and the async runtime updates. │ " -" " -" Input Press i to start typing · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tu " -" Ctrl+5 Icons: Nerd (auto) i " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__command_palette_focus@80x20.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__command_palette_focus@80x20.snap deleted file mode 100644 index 05917bd..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__command_palette_focus@80x20.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -assertion_line: 230 -expression: snapshot ---- -" Command Palette Ctrl+P " -" " -" :focus " -" " -" " -" Commands " -" code " -" Switch to code mode " -" System · Modes: Command · #mode #code #focus " -" › chat " -" Switch to chat mode " -" System · Modes: Command · #mode #chat #focus " -" files f 1 · Ctrl+1 / Alt+1 " -" Toggle the files panel " -" Navigation · Modes: Normal, Command · #focus #panel #navigation " -" " -" " -" Enter: run · Tab: autocomplete · /tag filter · Esc: cancel " -" " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__emacs_profile@emacs-110x30.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__emacs_profile@emacs-110x30.snap deleted file mode 100644 index a6b262e..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__emacs_profile@emacs-110x30.snap +++ /dev/null @@ -1,34 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metri Command Palette Ctrl+P " -" " -" :help " -" ▌ Chat · stub " -" " -" No messages Commands " -" › help F1 / ? " -" Open the help overlay " -" Support · Modes: Normal, Command · #help #docs #support " -" tutorial " -" Show keybinding tutorial " -" Support · Modes: Command · #help #tutorial #onboarding " -" h F1 / ? " -" Open the help overlay " -" Support · Modes: Normal, Command · #help #docs #support " -" " -" " -" ▌ Command En Enter: run · Tab: autocomplete · /tag filter · Esc: cancel " -" " -" " -" System/Status " -" " -" " -" COMMAND CHAT Focus INPUT ·ⓘ :help owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab1-100x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab1-100x24.snap deleted file mode 100644 index c5648d3..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab1-100x24.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" Focus & Modes │ Leader Actions │ Search & Commands " -" ▌ Chat · st " -" " -" No messag " -" Active keymap · Vim " -" Leader key · Space " -" " -" Files panel → Ctrl+1 / Space f 1 " -" Chat timeline → Ctrl+2 / Space f 2 " -" Input Pr Thinking panel → Ctrl+4 / Space f 4 " -" Code view → Ctrl+3 / Space f 3 " -" Input editor → Ctrl+5 / Space f 5 " -" System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close " -" " -" " -" HELP CHAT Focus INPUT · ⓘ Owlen cheat sheet owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab2-100x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab2-100x24.snap deleted file mode 100644 index 2d3b475..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab2-100x24.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" Focus & Modes │ Leader Actions │ Search & Commands " -" ▌ Chat · st " -" " -" No messag " -" Model & provider " -" Model picker → m / Space m " -" Command palette → Ctrl+P / Space t " -" Switch provider → Space p " -" Command mode → Ctrl+; / Space : " -" Input Pr " -" Layout " -" Split horizontal → Ctrl+W S / Space l s " -" System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close " -" " -" " -" HELP CHAT Focus INPUT · ⓘ Owlen cheat sheet owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step1-80x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step1-80x24.snap deleted file mode 100644 index df06f7f..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step1-80x24.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" Context metrics not available Cloud usage pending " -" " -" ▌ Chat · cus " -" Getting started · Step 1 of 3 Focus & movement (Vim) " -" No mess " -" " -" " -" Focus shortcuts " -" Chat timeline → Ctrl+2 / Space f 2 " -" Input editor → Ctrl+5 / Space f 5 " -" Files panel → Ctrl+1 / Space f 1 " -" Thinking panel → Ctrl+4 / Space f 4 " -" Input Code view → Ctrl+3 / Space f 3 " -" Tab / Shift+Tab → cycle panels forward/backward " -" Esc → return to Normal mode " -" System/S Enter/→ Next Esc Skip " -" " -" Normal F1/? | " -" " -" HELP CHAT Focus INPUT · ⓘ Welcome to Owlen! Press F1 for owlen-tu " -" Ctrl+5 Normal ▸ h/j/k/l • Insert ▸ i,a • i " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step2-100x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step2-100x24.snap deleted file mode 100644 index 905eb30..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step2-100x24.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" Getting started · Step 2 of 3 Leader actions (leader = Space) " -" ▌ Chat · st " -" " -" No messag " -" Model & provider " -" Model picker → m / Space m " -" Command palette → Ctrl+P / Space t " -" Switch provider → Space p " -" Command mode → Ctrl+; / Space : " -" Input Pr " -" Layout " -" Split horizontal → Ctrl+W S / Space l s " -" System/Sta Enter/→ Next Shift+Tab/← Back Esc Skip " -" " -" " -" HELP CHAT Focus INPUT · ⓘ Owlen onboarding · Step 2 of 3 owlen-tui " -" Ctrl+5 Normal ▸ h/j/k/l • Insert ▸ i,a • 1:1 · Plain Text · UTF-8 " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@command-90x28.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@command-90x28.snap deleted file mode 100644 index de4ce31..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@command-90x28.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Con Command Palette Ctrl+P " -" " -" :session save " -" ▌ C " -" " -" N Commands " -" › session save " -" Save the current conversation " -" Sessions · Modes: Command · #session #save #history " -" sessions " -" List saved sessions " -" Sessions · Modes: Command · #session #history #browse " -" load " -" Load a saved conversation " -" Sessions · Modes: Command · #session #restore #history " -" ▌ C Enter: run · Tab: autocomplete · /tag filter · Esc: cancel " -" " -" " -" System/Status " -" " -" " -" COMMAND CHAT Focus INPUT ·ⓘ :session save owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits · UTF-8 " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@editing-90x28.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@editing-90x28.snap deleted file mode 100644 index 8618060..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@editing-90x28.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" " -" No messages yet. Press 'i' to start typing. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" ▌ Input Enter send · Shift+Enter newline · Esc normal · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" INSERT CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:20 · Plain " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits Text · UTF-8 " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@normal-90x28.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@normal-90x28.snap deleted file mode 100644 index 8be5950..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@normal-90x28.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" " -" No messages yet. Press 'i' to start typing. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" Input Press i to start typing · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits · UTF-8 " -" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@visual-90x28.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@visual-90x28.snap deleted file mode 100644 index abdd7d1..0000000 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__mode_states@visual-90x28.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: crates/owlen-tui/tests/chat_snapshots.rs -expression: snapshot ---- -" " -" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " -" Context metrics not available Cloud usage pending " -" " -" " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" " -" ┌ 👤 You ────────────────────────────────────────────────────────────────────────┐ " -" │ Render visual selection across multiple lines. │ " -" └────────────────────────────────────────────────────────────────────────────────┘ " -" ┌ 🤖 Assistant ──────────────────────────────────────────────────────────────────┐ " -" │ Assistant reply for visual mode highlighting. │ " -" └────────────────────────────────────────────────────────────────────────────────┘ " -" " -" " -" " -" " -" ▌ Visual Select y yank · d cut · Esc cancel · Ctrl+5 focus " -" " -" " -" System/Status " -" " -" " -" VISUAL CHAT Focus INPUT · ⓘ -- VISUAL -- (move with j/k, yankowlen-tui " -" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text " -" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits · UTF-8 " -" " diff --git a/crates/owlen-tui/tests/state_tests.rs b/crates/owlen-tui/tests/state_tests.rs deleted file mode 100644 index 3890e20..0000000 --- a/crates/owlen-tui/tests/state_tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -use owlen_tui::commands; -use owlen_tui::state::CommandPalette; - -#[test] -fn palette_tracks_buffer_and_suggestions() { - let mut palette = CommandPalette::new(); - assert_eq!(palette.buffer(), ""); - assert!(palette.suggestions().is_empty()); - - palette.set_buffer("mo"); - assert_eq!(palette.buffer(), "mo"); - assert!(!palette.suggestions().is_empty()); - - palette.push_char('d'); - assert_eq!(palette.buffer(), "mod"); - assert!(!palette.suggestions().is_empty()); - - palette.pop_char(); - assert_eq!(palette.buffer(), "mo"); -} - -#[test] -fn palette_selection_wraps_safely() { - let mut palette = CommandPalette::new(); - palette.set_buffer("m"); - let suggestions = palette.suggestions().len(); - assert!(suggestions > 0); - - palette.select_previous(); - assert_eq!(palette.selected_index(), 0); - - for _ in 0..suggestions * 2 { - palette.select_next(); - } - assert!(palette.selected_index() < palette.suggestions().len()); -} - -#[test] -fn palette_apply_selected_updates_buffer() { - let mut palette = CommandPalette::new(); - palette.set_buffer("mo"); - palette.select_next(); - let selected = palette.apply_selected().expect("suggestion"); - assert_eq!(palette.buffer(), selected); - assert!(selected.starts_with("m")); -} - -#[test] -fn command_catalog_contains_expected_aliases() { - let mut keywords: Vec<&str> = Vec::new(); - for descriptor in commands::catalog() { - keywords.extend_from_slice(descriptor.keywords()); - } - assert!(keywords.contains(&"model")); - assert!(keywords.contains(&"open")); - assert!(keywords.contains(&"close")); - assert!(keywords.contains(&"sessions")); - assert!(keywords.contains(&"new")); -} diff --git a/crates/owlen-ui-common/Cargo.toml b/crates/owlen-ui-common/Cargo.toml deleted file mode 100644 index 20b8f78..0000000 --- a/crates/owlen-ui-common/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "owlen-ui-common" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -description = "UI-agnostic color and theme abstractions for OWLEN" - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -toml = { workspace = true } -shellexpand = { workspace = true } -dirs = { workspace = true } - -[dev-dependencies] diff --git a/crates/owlen-ui-common/src/color.rs b/crates/owlen-ui-common/src/color.rs deleted file mode 100644 index 4df59c5..0000000 --- a/crates/owlen-ui-common/src/color.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! UI-agnostic color abstraction for OWLEN -//! -//! This module provides a color type that can be used across different UI -//! implementations (TUI, GUI, etc.) without tying the core library to any -//! specific rendering framework. - -use serde::{Deserialize, Serialize}; - -/// An abstract color representation that can be converted to different UI frameworks -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum Color { - /// RGB color with red, green, and blue components (0-255) - Rgb(u8, u8, u8), - /// Named ANSI color - Named(NamedColor), -} - -/// Standard ANSI color names -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum NamedColor { - Black, - Red, - Green, - Yellow, - Blue, - Magenta, - Cyan, - Gray, - DarkGray, - LightRed, - LightGreen, - LightYellow, - LightBlue, - LightMagenta, - LightCyan, - White, -} - -impl Color { - /// Create an RGB color - pub const fn rgb(r: u8, g: u8, b: u8) -> Self { - Color::Rgb(r, g, b) - } - - /// Create a named color - pub const fn named(color: NamedColor) -> Self { - Color::Named(color) - } - - /// Convenience constructors for common colors - pub const fn black() -> Self { - Color::Named(NamedColor::Black) - } - - pub const fn white() -> Self { - Color::Named(NamedColor::White) - } - - pub const fn red() -> Self { - Color::Named(NamedColor::Red) - } - - pub const fn green() -> Self { - Color::Named(NamedColor::Green) - } - - pub const fn yellow() -> Self { - Color::Named(NamedColor::Yellow) - } - - pub const fn blue() -> Self { - Color::Named(NamedColor::Blue) - } - - pub const fn magenta() -> Self { - Color::Named(NamedColor::Magenta) - } - - pub const fn cyan() -> Self { - Color::Named(NamedColor::Cyan) - } - - pub const fn gray() -> Self { - Color::Named(NamedColor::Gray) - } - - pub const fn dark_gray() -> Self { - Color::Named(NamedColor::DarkGray) - } - - pub const fn light_red() -> Self { - Color::Named(NamedColor::LightRed) - } - - pub const fn light_green() -> Self { - Color::Named(NamedColor::LightGreen) - } - - pub const fn light_yellow() -> Self { - Color::Named(NamedColor::LightYellow) - } - - pub const fn light_blue() -> Self { - Color::Named(NamedColor::LightBlue) - } - - pub const fn light_magenta() -> Self { - Color::Named(NamedColor::LightMagenta) - } - - pub const fn light_cyan() -> Self { - Color::Named(NamedColor::LightCyan) - } -} - -/// Parse a color from a string representation -/// -/// Supports: -/// - Hex colors: "#ff0000", "#FF0000" -/// - Named colors: "red", "lightblue", etc. -pub fn parse_color(s: &str) -> Result { - if let Some(hex) = s.strip_prefix('#') - && hex.len() == 6 - { - let r = - u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: {}", s))?; - let g = - u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: {}", s))?; - let b = - u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: {}", s))?; - return Ok(Color::Rgb(r, g, b)); - } - - // Try named colors - match s.to_lowercase().as_str() { - "black" => Ok(Color::Named(NamedColor::Black)), - "red" => Ok(Color::Named(NamedColor::Red)), - "green" => Ok(Color::Named(NamedColor::Green)), - "yellow" => Ok(Color::Named(NamedColor::Yellow)), - "blue" => Ok(Color::Named(NamedColor::Blue)), - "magenta" => Ok(Color::Named(NamedColor::Magenta)), - "cyan" => Ok(Color::Named(NamedColor::Cyan)), - "gray" | "grey" => Ok(Color::Named(NamedColor::Gray)), - "darkgray" | "darkgrey" => Ok(Color::Named(NamedColor::DarkGray)), - "lightred" => Ok(Color::Named(NamedColor::LightRed)), - "lightgreen" => Ok(Color::Named(NamedColor::LightGreen)), - "lightyellow" => Ok(Color::Named(NamedColor::LightYellow)), - "lightblue" => Ok(Color::Named(NamedColor::LightBlue)), - "lightmagenta" => Ok(Color::Named(NamedColor::LightMagenta)), - "lightcyan" => Ok(Color::Named(NamedColor::LightCyan)), - "white" => Ok(Color::Named(NamedColor::White)), - _ => Err(format!("Unknown color: {}", s)), - } -} - -/// Convert a color to its string representation -pub fn color_to_string(color: &Color) -> String { - match color { - Color::Named(NamedColor::Black) => "black".to_string(), - Color::Named(NamedColor::Red) => "red".to_string(), - Color::Named(NamedColor::Green) => "green".to_string(), - Color::Named(NamedColor::Yellow) => "yellow".to_string(), - Color::Named(NamedColor::Blue) => "blue".to_string(), - Color::Named(NamedColor::Magenta) => "magenta".to_string(), - Color::Named(NamedColor::Cyan) => "cyan".to_string(), - Color::Named(NamedColor::Gray) => "gray".to_string(), - Color::Named(NamedColor::DarkGray) => "darkgray".to_string(), - Color::Named(NamedColor::LightRed) => "lightred".to_string(), - Color::Named(NamedColor::LightGreen) => "lightgreen".to_string(), - Color::Named(NamedColor::LightYellow) => "lightyellow".to_string(), - Color::Named(NamedColor::LightBlue) => "lightblue".to_string(), - Color::Named(NamedColor::LightMagenta) => "lightmagenta".to_string(), - Color::Named(NamedColor::LightCyan) => "lightcyan".to_string(), - Color::Named(NamedColor::White) => "white".to_string(), - Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_color_parsing() { - assert_eq!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0))); - assert_eq!(parse_color("red"), Ok(Color::Named(NamedColor::Red))); - assert_eq!( - parse_color("lightblue"), - Ok(Color::Named(NamedColor::LightBlue)) - ); - assert!(parse_color("invalid").is_err()); - } - - #[test] - fn test_color_to_string() { - assert_eq!(color_to_string(&Color::Named(NamedColor::Red)), "red"); - assert_eq!(color_to_string(&Color::Rgb(255, 0, 0)), "#ff0000"); - } - - #[test] - fn test_color_constructors() { - assert_eq!(Color::black(), Color::Named(NamedColor::Black)); - assert_eq!(Color::rgb(255, 0, 0), Color::Rgb(255, 0, 0)); - } -} diff --git a/crates/owlen-ui-common/src/lib.rs b/crates/owlen-ui-common/src/lib.rs deleted file mode 100644 index 103c2ea..0000000 --- a/crates/owlen-ui-common/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! UI-agnostic abstractions for OWLEN -//! -//! This crate provides color and theme abstractions that can be used across -//! different UI implementations (TUI, GUI, etc.) without tying the core library -//! to any specific rendering framework. - -pub mod color; -pub mod theme; - -// Re-export commonly used types -pub use color::{Color, NamedColor, color_to_string, parse_color}; -pub use theme::{ - Theme, ThemePalette, built_in_themes, default_themes_dir, get_theme, load_all_themes, -}; diff --git a/crates/owlen-ui-common/src/theme.rs b/crates/owlen-ui-common/src/theme.rs deleted file mode 100644 index 2ecc367..0000000 --- a/crates/owlen-ui-common/src/theme.rs +++ /dev/null @@ -1,1175 +0,0 @@ -//! Theming system for OWLEN TUI -//! -//! Provides customizable color schemes for all UI components. - -use crate::color::{Color, NamedColor, color_to_string, parse_color}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -pub type ThemePalette = Theme; - -/// A complete theme definition for OWLEN TUI -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Theme { - /// Name of the theme - pub name: String, - - /// Default text color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub text: Color, - - /// Default background color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub background: Color, - - /// Border color for focused panels - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub focused_panel_border: Color, - - /// Border color for unfocused panels - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub unfocused_panel_border: Color, - - /// Foreground color for the active pane beacon (`▌`) - #[serde(default = "Theme::default_focus_beacon_fg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub focus_beacon_fg: Color, - - /// Background color for the active pane beacon (`▌`) - #[serde(default = "Theme::default_focus_beacon_bg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub focus_beacon_bg: Color, - - /// Foreground color for the inactive pane beacon (`▌`) - #[serde(default = "Theme::default_unfocused_beacon_fg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub unfocused_beacon_fg: Color, - - /// Title color for active pane headers - #[serde(default = "Theme::default_pane_header_active")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub pane_header_active: Color, - - /// Title color for inactive pane headers - #[serde(default = "Theme::default_pane_header_inactive")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub pane_header_inactive: Color, - - /// Hint text color used within pane headers - #[serde(default = "Theme::default_pane_hint_text")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub pane_hint_text: Color, - - /// Color for user message role indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub user_message_role: Color, - - /// Color for assistant message role indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub assistant_message_role: Color, - - /// Color for tool output messages - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub tool_output: Color, - - /// Color for thinking panel title - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub thinking_panel_title: Color, - - /// Background color for command bar - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub command_bar_background: Color, - - /// Status line background color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub status_background: Color, - - /// Color for Normal mode indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub mode_normal: Color, - - /// Color for Editing mode indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub mode_editing: Color, - - /// Color for Model Selection mode indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub mode_model_selection: Color, - - /// Color for Provider Selection mode indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub mode_provider_selection: Color, - - /// Color for Help mode indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub mode_help: Color, - - /// Color for Visual mode indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub mode_visual: Color, - - /// Color for Command mode indicator - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub mode_command: Color, - - /// Selection/highlight background color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub selection_bg: Color, - - /// Selection/highlight foreground color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub selection_fg: Color, - - /// Cursor indicator color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub cursor: Color, - - /// Code block background color - #[serde(default = "Theme::default_code_block_background")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub code_block_background: Color, - - /// Code block border color - #[serde(default = "Theme::default_code_block_border")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub code_block_border: Color, - - /// Code block text color - #[serde(default = "Theme::default_code_block_text")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub code_block_text: Color, - - /// Code block keyword color - #[serde(default = "Theme::default_code_block_keyword")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub code_block_keyword: Color, - - /// Code block string literal color - #[serde(default = "Theme::default_code_block_string")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub code_block_string: Color, - - /// Code block comment color - #[serde(default = "Theme::default_code_block_comment")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub code_block_comment: Color, - - /// Placeholder text color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub placeholder: Color, - - /// Warning/error message color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub error: Color, - - /// Success/info message color - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub info: Color, - - /// Agent action coloring (ReAct THOUGHT) - #[serde(default = "Theme::default_agent_thought")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_thought: Color, - - /// Agent action coloring (ReAct ACTION) - #[serde(default = "Theme::default_agent_action")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_action: Color, - - /// Agent action coloring (ReAct ACTION_INPUT) - #[serde(default = "Theme::default_agent_action_input")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_action_input: Color, - - /// Agent action coloring (ReAct OBSERVATION) - #[serde(default = "Theme::default_agent_observation")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_observation: Color, - - /// Agent action coloring (ReAct FINAL_ANSWER) - #[serde(default = "Theme::default_agent_final_answer")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_final_answer: Color, - - /// Status badge foreground when agent is running - #[serde(default = "Theme::default_agent_badge_running_fg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_badge_running_fg: Color, - - /// Status badge background when agent is running - #[serde(default = "Theme::default_agent_badge_running_bg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_badge_running_bg: Color, - - /// Status badge foreground when agent mode is idle - #[serde(default = "Theme::default_agent_badge_idle_fg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_badge_idle_fg: Color, - - /// Status badge background when agent mode is idle - #[serde(default = "Theme::default_agent_badge_idle_bg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub agent_badge_idle_bg: Color, - - /// Operating mode badge foreground (Chat) - #[serde(default = "Theme::default_operating_chat_fg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub operating_chat_fg: Color, - - /// Operating mode badge background (Chat) - #[serde(default = "Theme::default_operating_chat_bg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub operating_chat_bg: Color, - - /// Operating mode badge foreground (Code) - #[serde(default = "Theme::default_operating_code_fg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub operating_code_fg: Color, - - /// Operating mode badge background (Code) - #[serde(default = "Theme::default_operating_code_bg")] - #[serde(deserialize_with = "deserialize_color")] - #[serde(serialize_with = "serialize_color")] - pub operating_code_bg: Color, -} - -impl Default for Theme { - fn default() -> Self { - default_dark() - } -} - -impl Theme { - const fn default_code_block_background() -> Color { - Color::Named(NamedColor::Black) - } - - const fn default_code_block_border() -> Color { - Color::Named(NamedColor::Gray) - } - - const fn default_code_block_text() -> Color { - Color::Named(NamedColor::White) - } - - const fn default_code_block_keyword() -> Color { - Color::Named(NamedColor::Yellow) - } - - const fn default_code_block_string() -> Color { - Color::Named(NamedColor::LightGreen) - } - - const fn default_code_block_comment() -> Color { - Color::Named(NamedColor::DarkGray) - } - - const fn default_agent_thought() -> Color { - Color::Named(NamedColor::LightBlue) - } - - const fn default_agent_action() -> Color { - Color::Named(NamedColor::Yellow) - } - - const fn default_agent_action_input() -> Color { - Color::Named(NamedColor::LightCyan) - } - - const fn default_agent_observation() -> Color { - Color::Named(NamedColor::LightGreen) - } - - const fn default_agent_final_answer() -> Color { - Color::Named(NamedColor::Magenta) - } - - const fn default_agent_badge_running_fg() -> Color { - Color::Named(NamedColor::Black) - } - - const fn default_agent_badge_running_bg() -> Color { - Color::Named(NamedColor::Yellow) - } - - const fn default_agent_badge_idle_fg() -> Color { - Color::Named(NamedColor::Black) - } - - const fn default_agent_badge_idle_bg() -> Color { - Color::Named(NamedColor::Cyan) - } - - const fn default_focus_beacon_fg() -> Color { - Color::Named(NamedColor::LightMagenta) - } - - const fn default_focus_beacon_bg() -> Color { - Color::Named(NamedColor::Black) - } - - const fn default_unfocused_beacon_fg() -> Color { - Color::Named(NamedColor::DarkGray) - } - - const fn default_pane_header_active() -> Color { - Color::Named(NamedColor::White) - } - - const fn default_pane_header_inactive() -> Color { - Color::Named(NamedColor::Gray) - } - - const fn default_pane_hint_text() -> Color { - Color::Named(NamedColor::DarkGray) - } - - const fn default_operating_chat_fg() -> Color { - Color::Named(NamedColor::Black) - } - - const fn default_operating_chat_bg() -> Color { - Color::Named(NamedColor::Blue) - } - - const fn default_operating_code_fg() -> Color { - Color::Named(NamedColor::Black) - } - - const fn default_operating_code_bg() -> Color { - Color::Named(NamedColor::Magenta) - } -} - -/// Get the default themes directory path -/// Note: This uses a hardcoded default path that matches owlen-core's DEFAULT_CONFIG_PATH -pub fn default_themes_dir() -> PathBuf { - // Use a hardcoded default path that matches owlen-core's DEFAULT_CONFIG_PATH - let config_dir = PathBuf::from(shellexpand::tilde("~/.config/owlen").as_ref()); - config_dir.join("themes") -} - -/// Load all available themes (built-in + custom) -pub fn load_all_themes() -> HashMap { - let mut themes = HashMap::new(); - - // Load built-in themes - for (name, theme) in built_in_themes() { - themes.insert(name, theme); - } - - // Load custom themes from disk - let themes_dir = default_themes_dir(); - if let Ok(entries) = fs::read_dir(&themes_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("toml") { - let name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - - match load_theme_from_file(&path) { - Ok(theme) => { - themes.insert(name.clone(), theme); - } - Err(e) => { - eprintln!("Warning: Failed to load custom theme '{}': {}", name, e); - } - } - } - } - } - - themes -} - -/// Load a theme from a TOML file -pub fn load_theme_from_file(path: &Path) -> Result { - let content = - fs::read_to_string(path).map_err(|e| format!("Failed to read theme file: {}", e))?; - - toml::from_str(&content).map_err(|e| format!("Failed to parse theme file: {}", e)) -} - -/// Get a theme by name (built-in or custom) -pub fn get_theme(name: &str) -> Option { - load_all_themes().get(name).cloned() -} - -/// Get all built-in themes (embedded in the binary) -pub fn built_in_themes() -> HashMap { - let mut themes = HashMap::new(); - - // Load embedded theme files - let embedded_themes = [ - ( - "default_dark", - include_str!("../../../themes/default_dark.toml"), - ), - ( - "default_light", - include_str!("../../../themes/default_light.toml"), - ), - ( - "ansi_basic", - include_str!("../../../themes/ansi-basic.toml"), - ), - ( - "grayscale-high-contrast", - include_str!("../../../themes/grayscale-high-contrast.toml"), - ), - ("gruvbox", include_str!("../../../themes/gruvbox.toml")), - ("dracula", include_str!("../../../themes/dracula.toml")), - ("solarized", include_str!("../../../themes/solarized.toml")), - ( - "midnight-ocean", - include_str!("../../../themes/midnight-ocean.toml"), - ), - ("rose-pine", include_str!("../../../themes/rose-pine.toml")), - ("monokai", include_str!("../../../themes/monokai.toml")), - ( - "material-dark", - include_str!("../../../themes/material-dark.toml"), - ), - ( - "material-light", - include_str!("../../../themes/material-light.toml"), - ), - ]; - - for (name, content) in embedded_themes { - match toml::from_str::(content) { - Ok(theme) => { - themes.insert(name.to_string(), theme); - } - Err(e) => { - eprintln!("Warning: Failed to parse built-in theme '{}': {}", name, e); - // Fallback to hardcoded version if parsing fails - if let Some(fallback) = get_fallback_theme(name) { - themes.insert(name.to_string(), fallback); - } - } - } - } - - themes -} - -/// Get fallback hardcoded theme (used if embedded TOML fails to parse) -fn get_fallback_theme(name: &str) -> Option { - match name { - "default_dark" => Some(default_dark()), - "default_light" => Some(default_light()), - "gruvbox" => Some(gruvbox()), - "dracula" => Some(dracula()), - "solarized" => Some(solarized()), - "midnight-ocean" => Some(midnight_ocean()), - "rose-pine" => Some(rose_pine()), - "monokai" => Some(monokai()), - "material-dark" => Some(material_dark()), - "material-light" => Some(material_light()), - "grayscale-high-contrast" => Some(grayscale_high_contrast()), - _ => None, - } -} - -/// Default dark theme -fn default_dark() -> Theme { - Theme { - name: "default_dark".to_string(), - text: Color::Named(NamedColor::White), - background: Color::Named(NamedColor::Black), - focused_panel_border: Color::Rgb(216, 160, 255), - unfocused_panel_border: Color::Rgb(137, 82, 204), - focus_beacon_fg: Color::Rgb(248, 229, 255), - focus_beacon_bg: Color::Rgb(38, 10, 58), - unfocused_beacon_fg: Color::Rgb(130, 130, 130), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Color::Rgb(210, 210, 210), - pane_hint_text: Color::Rgb(210, 210, 210), - user_message_role: Color::Named(NamedColor::LightBlue), - assistant_message_role: Color::Named(NamedColor::Yellow), - tool_output: Color::Rgb(200, 200, 200), - thinking_panel_title: Color::Rgb(234, 182, 255), - command_bar_background: Color::Rgb(10, 10, 10), - status_background: Color::Rgb(12, 12, 12), - mode_normal: Color::Rgb(117, 200, 255), - mode_editing: Color::Rgb(144, 242, 170), - mode_model_selection: Color::Rgb(255, 226, 140), - mode_provider_selection: Color::Rgb(164, 235, 255), - mode_help: Color::Rgb(234, 182, 255), - mode_visual: Color::Rgb(255, 170, 255), - mode_command: Color::Rgb(255, 220, 120), - selection_bg: Color::Rgb(56, 140, 240), - selection_fg: Color::Named(NamedColor::Black), - cursor: Color::Rgb(255, 196, 255), - code_block_background: Color::Rgb(25, 25, 25), - code_block_border: Color::Rgb(216, 160, 255), - code_block_text: Color::Named(NamedColor::White), - code_block_keyword: Color::Rgb(255, 220, 120), - code_block_string: Color::Rgb(144, 242, 170), - code_block_comment: Color::Rgb(170, 170, 170), - placeholder: Color::Rgb(180, 180, 180), - error: Color::Named(NamedColor::Red), - info: Color::Rgb(144, 242, 170), - agent_thought: Color::Rgb(117, 200, 255), - agent_action: Color::Rgb(255, 220, 120), - agent_action_input: Color::Rgb(164, 235, 255), - agent_observation: Color::Rgb(144, 242, 170), - agent_final_answer: Color::Rgb(255, 170, 255), - agent_badge_running_fg: Color::Named(NamedColor::Black), - agent_badge_running_bg: Color::Named(NamedColor::Yellow), - agent_badge_idle_fg: Color::Named(NamedColor::Black), - agent_badge_idle_bg: Color::Named(NamedColor::Cyan), - operating_chat_fg: Color::Named(NamedColor::Black), - operating_chat_bg: Color::Rgb(117, 200, 255), - operating_code_fg: Color::Named(NamedColor::Black), - operating_code_bg: Color::Rgb(255, 170, 255), - } -} - -/// Default light theme -fn default_light() -> Theme { - Theme { - name: "default_light".to_string(), - text: Color::Named(NamedColor::Black), - background: Color::Named(NamedColor::White), - focused_panel_border: Color::Rgb(74, 144, 226), - unfocused_panel_border: Color::Rgb(221, 221, 221), - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(0, 85, 164), - assistant_message_role: Color::Rgb(142, 68, 173), - tool_output: Color::Named(NamedColor::Gray), - thinking_panel_title: Color::Rgb(142, 68, 173), - command_bar_background: Color::Named(NamedColor::White), - status_background: Color::Named(NamedColor::White), - mode_normal: Color::Rgb(0, 85, 164), - mode_editing: Color::Rgb(46, 139, 87), - mode_model_selection: Color::Rgb(181, 137, 0), - mode_provider_selection: Color::Rgb(0, 139, 139), - mode_help: Color::Rgb(142, 68, 173), - mode_visual: Color::Rgb(142, 68, 173), - mode_command: Color::Rgb(181, 137, 0), - selection_bg: Color::Rgb(164, 200, 240), - selection_fg: Color::Named(NamedColor::Black), - cursor: Color::Rgb(217, 95, 2), - code_block_background: Color::Rgb(245, 245, 245), - code_block_border: Color::Rgb(142, 68, 173), - code_block_text: Color::Named(NamedColor::Black), - code_block_keyword: Color::Rgb(181, 137, 0), - code_block_string: Color::Rgb(46, 139, 87), - code_block_comment: Color::Named(NamedColor::Gray), - placeholder: Color::Named(NamedColor::Gray), - error: Color::Rgb(192, 57, 43), - info: Color::Named(NamedColor::Green), - agent_thought: Color::Rgb(0, 85, 164), - agent_action: Color::Rgb(181, 137, 0), - agent_action_input: Color::Rgb(0, 139, 139), - agent_observation: Color::Rgb(46, 139, 87), - agent_final_answer: Color::Rgb(142, 68, 173), - agent_badge_running_fg: Color::Named(NamedColor::White), - agent_badge_running_bg: Color::Rgb(241, 196, 15), - agent_badge_idle_fg: Color::Named(NamedColor::White), - agent_badge_idle_bg: Color::Rgb(0, 150, 136), - operating_chat_fg: Color::Named(NamedColor::White), - operating_chat_bg: Color::Rgb(0, 85, 164), - operating_code_fg: Color::Named(NamedColor::White), - operating_code_bg: Color::Rgb(142, 68, 173), - } -} - -/// Gruvbox theme -fn gruvbox() -> Theme { - Theme { - name: "gruvbox".to_string(), - text: Color::Rgb(235, 219, 178), // #ebdbb2 - background: Color::Rgb(40, 40, 40), // #282828 - focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange) - unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64 - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green) - assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue) - tool_output: Color::Rgb(146, 131, 116), - thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple) - command_bar_background: Color::Rgb(60, 56, 54), // #3c3836 - status_background: Color::Rgb(60, 56, 54), - mode_normal: Color::Rgb(131, 165, 152), // blue - mode_editing: Color::Rgb(184, 187, 38), // green - mode_model_selection: Color::Rgb(250, 189, 47), // yellow - mode_provider_selection: Color::Rgb(142, 192, 124), // aqua - mode_help: Color::Rgb(211, 134, 155), // purple - mode_visual: Color::Rgb(254, 128, 25), // orange - mode_command: Color::Rgb(250, 189, 47), // yellow - selection_bg: Color::Rgb(80, 73, 69), - selection_fg: Color::Rgb(235, 219, 178), - cursor: Color::Rgb(254, 128, 25), - code_block_background: Color::Rgb(60, 56, 54), - code_block_border: Color::Rgb(124, 111, 100), - code_block_text: Color::Rgb(235, 219, 178), - code_block_keyword: Color::Rgb(250, 189, 47), - code_block_string: Color::Rgb(142, 192, 124), - code_block_comment: Color::Rgb(124, 111, 100), - placeholder: Color::Rgb(102, 92, 84), - error: Color::Rgb(251, 73, 52), // #fb4934 - info: Color::Rgb(184, 187, 38), - agent_thought: Color::Rgb(131, 165, 152), - agent_action: Color::Rgb(250, 189, 47), - agent_action_input: Color::Rgb(142, 192, 124), - agent_observation: Color::Rgb(184, 187, 38), - agent_final_answer: Color::Rgb(211, 134, 155), - agent_badge_running_fg: Color::Rgb(40, 40, 40), - agent_badge_running_bg: Color::Rgb(250, 189, 47), - agent_badge_idle_fg: Color::Rgb(40, 40, 40), - agent_badge_idle_bg: Color::Rgb(131, 165, 152), - operating_chat_fg: Color::Rgb(40, 40, 40), - operating_chat_bg: Color::Rgb(131, 165, 152), - operating_code_fg: Color::Rgb(40, 40, 40), - operating_code_bg: Color::Rgb(211, 134, 155), - } -} - -/// Dracula theme -fn dracula() -> Theme { - Theme { - name: "dracula".to_string(), - text: Color::Rgb(248, 248, 242), // #f8f8f2 - background: Color::Rgb(40, 42, 54), // #282a36 - focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink) - unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan) - assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink) - tool_output: Color::Rgb(98, 114, 164), - thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple) - command_bar_background: Color::Rgb(68, 71, 90), - status_background: Color::Rgb(68, 71, 90), - mode_normal: Color::Rgb(139, 233, 253), - mode_editing: Color::Rgb(80, 250, 123), // #50fa7b (green) - mode_model_selection: Color::Rgb(241, 250, 140), // #f1fa8c (yellow) - mode_provider_selection: Color::Rgb(139, 233, 253), - mode_help: Color::Rgb(189, 147, 249), - mode_visual: Color::Rgb(255, 121, 198), - mode_command: Color::Rgb(241, 250, 140), - selection_bg: Color::Rgb(68, 71, 90), - selection_fg: Color::Rgb(248, 248, 242), - cursor: Color::Rgb(255, 121, 198), - code_block_background: Color::Rgb(68, 71, 90), - code_block_border: Color::Rgb(189, 147, 249), - code_block_text: Color::Rgb(248, 248, 242), - code_block_keyword: Color::Rgb(255, 121, 198), - code_block_string: Color::Rgb(80, 250, 123), - code_block_comment: Color::Rgb(98, 114, 164), - placeholder: Color::Rgb(98, 114, 164), - error: Color::Rgb(255, 85, 85), // #ff5555 - info: Color::Rgb(80, 250, 123), - agent_thought: Color::Rgb(139, 233, 253), - agent_action: Color::Rgb(241, 250, 140), - agent_action_input: Color::Rgb(189, 147, 249), - agent_observation: Color::Rgb(80, 250, 123), - agent_final_answer: Color::Rgb(255, 121, 198), - agent_badge_running_fg: Color::Rgb(40, 42, 54), - agent_badge_running_bg: Color::Rgb(241, 250, 140), - agent_badge_idle_fg: Color::Rgb(40, 42, 54), - agent_badge_idle_bg: Color::Rgb(139, 233, 253), - operating_chat_fg: Color::Rgb(40, 42, 54), - operating_chat_bg: Color::Rgb(139, 233, 253), - operating_code_fg: Color::Rgb(40, 42, 54), - operating_code_bg: Color::Rgb(189, 147, 249), - } -} - -/// Solarized Dark theme -fn solarized() -> Theme { - Theme { - name: "solarized".to_string(), - text: Color::Rgb(131, 148, 150), // #839496 (base0) - background: Color::Rgb(0, 43, 54), // #002b36 (base03) - focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue) - unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02) - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan) - assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange) - tool_output: Color::Rgb(101, 123, 131), - thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet) - command_bar_background: Color::Rgb(7, 54, 66), - status_background: Color::Rgb(7, 54, 66), - mode_normal: Color::Rgb(38, 139, 210), // blue - mode_editing: Color::Rgb(133, 153, 0), // #859900 (green) - mode_model_selection: Color::Rgb(181, 137, 0), // #b58900 (yellow) - mode_provider_selection: Color::Rgb(42, 161, 152), // cyan - mode_help: Color::Rgb(108, 113, 196), // violet - mode_visual: Color::Rgb(211, 54, 130), // #d33682 (magenta) - mode_command: Color::Rgb(181, 137, 0), // yellow - selection_bg: Color::Rgb(7, 54, 66), - selection_fg: Color::Rgb(147, 161, 161), - cursor: Color::Rgb(211, 54, 130), - code_block_background: Color::Rgb(7, 54, 66), - code_block_border: Color::Rgb(38, 139, 210), - code_block_text: Color::Rgb(147, 161, 161), - code_block_keyword: Color::Rgb(181, 137, 0), - code_block_string: Color::Rgb(133, 153, 0), - code_block_comment: Color::Rgb(88, 110, 117), - placeholder: Color::Rgb(88, 110, 117), - error: Color::Rgb(220, 50, 47), // #dc322f (red) - info: Color::Rgb(133, 153, 0), - agent_thought: Color::Rgb(42, 161, 152), - agent_action: Color::Rgb(181, 137, 0), - agent_action_input: Color::Rgb(38, 139, 210), - agent_observation: Color::Rgb(133, 153, 0), - agent_final_answer: Color::Rgb(108, 113, 196), - agent_badge_running_fg: Color::Rgb(0, 43, 54), - agent_badge_running_bg: Color::Rgb(181, 137, 0), - agent_badge_idle_fg: Color::Rgb(0, 43, 54), - agent_badge_idle_bg: Color::Rgb(42, 161, 152), - operating_chat_fg: Color::Rgb(0, 43, 54), - operating_chat_bg: Color::Rgb(42, 161, 152), - operating_code_fg: Color::Rgb(0, 43, 54), - operating_code_bg: Color::Rgb(108, 113, 196), - } -} - -/// Midnight Ocean theme -fn midnight_ocean() -> Theme { - Theme { - name: "midnight-ocean".to_string(), - text: Color::Rgb(192, 202, 245), - background: Color::Rgb(13, 17, 23), - focused_panel_border: Color::Rgb(88, 166, 255), - unfocused_panel_border: Color::Rgb(48, 54, 61), - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(121, 192, 255), - assistant_message_role: Color::Rgb(137, 221, 255), - tool_output: Color::Rgb(84, 110, 122), - thinking_panel_title: Color::Rgb(158, 206, 106), - command_bar_background: Color::Rgb(22, 27, 34), - status_background: Color::Rgb(22, 27, 34), - mode_normal: Color::Rgb(121, 192, 255), - mode_editing: Color::Rgb(158, 206, 106), - mode_model_selection: Color::Rgb(255, 212, 59), - mode_provider_selection: Color::Rgb(137, 221, 255), - mode_help: Color::Rgb(255, 115, 157), - mode_visual: Color::Rgb(246, 140, 245), - mode_command: Color::Rgb(255, 212, 59), - selection_bg: Color::Rgb(56, 139, 253), - selection_fg: Color::Rgb(13, 17, 23), - cursor: Color::Rgb(246, 140, 245), - code_block_background: Color::Rgb(22, 27, 34), - code_block_border: Color::Rgb(88, 166, 255), - code_block_text: Color::Rgb(192, 202, 245), - code_block_keyword: Color::Rgb(255, 212, 59), - code_block_string: Color::Rgb(158, 206, 106), - code_block_comment: Color::Rgb(110, 118, 129), - placeholder: Color::Rgb(110, 118, 129), - error: Color::Rgb(248, 81, 73), - info: Color::Rgb(158, 206, 106), - agent_thought: Color::Rgb(121, 192, 255), - agent_action: Color::Rgb(255, 212, 59), - agent_action_input: Color::Rgb(137, 221, 255), - agent_observation: Color::Rgb(158, 206, 106), - agent_final_answer: Color::Rgb(246, 140, 245), - agent_badge_running_fg: Color::Rgb(13, 17, 23), - agent_badge_running_bg: Color::Rgb(255, 212, 59), - agent_badge_idle_fg: Color::Rgb(13, 17, 23), - agent_badge_idle_bg: Color::Rgb(137, 221, 255), - operating_chat_fg: Color::Rgb(13, 17, 23), - operating_chat_bg: Color::Rgb(121, 192, 255), - operating_code_fg: Color::Rgb(13, 17, 23), - operating_code_bg: Color::Rgb(246, 140, 245), - } -} - -/// Rose Pine theme -fn rose_pine() -> Theme { - Theme { - name: "rose-pine".to_string(), - text: Color::Rgb(224, 222, 244), // #e0def4 - background: Color::Rgb(25, 23, 36), // #191724 - focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love) - unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam) - assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light) - tool_output: Color::Rgb(110, 106, 134), - thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris) - command_bar_background: Color::Rgb(38, 35, 58), - status_background: Color::Rgb(38, 35, 58), - mode_normal: Color::Rgb(156, 207, 216), - mode_editing: Color::Rgb(235, 188, 186), // #ebbcba (rose) - mode_model_selection: Color::Rgb(246, 193, 119), - mode_provider_selection: Color::Rgb(49, 116, 143), - mode_help: Color::Rgb(196, 167, 231), - mode_visual: Color::Rgb(235, 111, 146), - mode_command: Color::Rgb(246, 193, 119), - selection_bg: Color::Rgb(64, 61, 82), - selection_fg: Color::Rgb(224, 222, 244), - cursor: Color::Rgb(235, 111, 146), - code_block_background: Color::Rgb(38, 35, 58), - code_block_border: Color::Rgb(235, 111, 146), - code_block_text: Color::Rgb(224, 222, 244), - code_block_keyword: Color::Rgb(246, 193, 119), - code_block_string: Color::Rgb(156, 207, 216), - code_block_comment: Color::Rgb(110, 106, 134), - placeholder: Color::Rgb(110, 106, 134), - error: Color::Rgb(235, 111, 146), - info: Color::Rgb(156, 207, 216), - agent_thought: Color::Rgb(156, 207, 216), - agent_action: Color::Rgb(246, 193, 119), - agent_action_input: Color::Rgb(196, 167, 231), - agent_observation: Color::Rgb(235, 188, 186), - agent_final_answer: Color::Rgb(235, 111, 146), - agent_badge_running_fg: Color::Rgb(25, 23, 36), - agent_badge_running_bg: Color::Rgb(246, 193, 119), - agent_badge_idle_fg: Color::Rgb(25, 23, 36), - agent_badge_idle_bg: Color::Rgb(156, 207, 216), - operating_chat_fg: Color::Rgb(25, 23, 36), - operating_chat_bg: Color::Rgb(156, 207, 216), - operating_code_fg: Color::Rgb(25, 23, 36), - operating_code_bg: Color::Rgb(196, 167, 231), - } -} - -/// Monokai theme -fn monokai() -> Theme { - Theme { - name: "monokai".to_string(), - text: Color::Rgb(248, 248, 242), // #f8f8f2 - background: Color::Rgb(39, 40, 34), // #272822 - focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink) - unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan) - assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple) - tool_output: Color::Rgb(117, 113, 94), - thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow) - command_bar_background: Color::Rgb(39, 40, 34), - status_background: Color::Rgb(39, 40, 34), - mode_normal: Color::Rgb(102, 217, 239), - mode_editing: Color::Rgb(166, 226, 46), // #a6e22e (green) - mode_model_selection: Color::Rgb(230, 219, 116), - mode_provider_selection: Color::Rgb(102, 217, 239), - mode_help: Color::Rgb(174, 129, 255), - mode_visual: Color::Rgb(249, 38, 114), - mode_command: Color::Rgb(230, 219, 116), - selection_bg: Color::Rgb(117, 113, 94), - selection_fg: Color::Rgb(248, 248, 242), - cursor: Color::Rgb(249, 38, 114), - code_block_background: Color::Rgb(50, 51, 46), - code_block_border: Color::Rgb(249, 38, 114), - code_block_text: Color::Rgb(248, 248, 242), - code_block_keyword: Color::Rgb(230, 219, 116), - code_block_string: Color::Rgb(166, 226, 46), - code_block_comment: Color::Rgb(117, 113, 94), - placeholder: Color::Rgb(117, 113, 94), - error: Color::Rgb(249, 38, 114), - info: Color::Rgb(166, 226, 46), - agent_thought: Color::Rgb(102, 217, 239), - agent_action: Color::Rgb(230, 219, 116), - agent_action_input: Color::Rgb(174, 129, 255), - agent_observation: Color::Rgb(166, 226, 46), - agent_final_answer: Color::Rgb(249, 38, 114), - agent_badge_running_fg: Color::Rgb(39, 40, 34), - agent_badge_running_bg: Color::Rgb(230, 219, 116), - agent_badge_idle_fg: Color::Rgb(39, 40, 34), - agent_badge_idle_bg: Color::Rgb(102, 217, 239), - operating_chat_fg: Color::Rgb(39, 40, 34), - operating_chat_bg: Color::Rgb(102, 217, 239), - operating_code_fg: Color::Rgb(39, 40, 34), - operating_code_bg: Color::Rgb(174, 129, 255), - } -} - -/// Material Dark theme -fn material_dark() -> Theme { - Theme { - name: "material-dark".to_string(), - text: Color::Rgb(238, 255, 255), // #eeffff - background: Color::Rgb(38, 50, 56), // #263238 - focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan) - unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue) - assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple) - tool_output: Color::Rgb(84, 110, 122), - thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow) - command_bar_background: Color::Rgb(33, 43, 48), - status_background: Color::Rgb(33, 43, 48), - mode_normal: Color::Rgb(130, 170, 255), - mode_editing: Color::Rgb(195, 232, 141), // #c3e88d (green) - mode_model_selection: Color::Rgb(255, 203, 107), - mode_provider_selection: Color::Rgb(128, 203, 196), - mode_help: Color::Rgb(199, 146, 234), - mode_visual: Color::Rgb(240, 113, 120), // #f07178 (red) - mode_command: Color::Rgb(255, 203, 107), - selection_bg: Color::Rgb(84, 110, 122), - selection_fg: Color::Rgb(238, 255, 255), - cursor: Color::Rgb(255, 204, 0), - code_block_background: Color::Rgb(33, 43, 48), - code_block_border: Color::Rgb(128, 203, 196), - code_block_text: Color::Rgb(238, 255, 255), - code_block_keyword: Color::Rgb(255, 203, 107), - code_block_string: Color::Rgb(195, 232, 141), - code_block_comment: Color::Rgb(84, 110, 122), - placeholder: Color::Rgb(84, 110, 122), - error: Color::Rgb(240, 113, 120), - info: Color::Rgb(195, 232, 141), - agent_thought: Color::Rgb(128, 203, 196), - agent_action: Color::Rgb(255, 203, 107), - agent_action_input: Color::Rgb(199, 146, 234), - agent_observation: Color::Rgb(195, 232, 141), - agent_final_answer: Color::Rgb(240, 113, 120), - agent_badge_running_fg: Color::Rgb(38, 50, 56), - agent_badge_running_bg: Color::Rgb(255, 203, 107), - agent_badge_idle_fg: Color::Rgb(38, 50, 56), - agent_badge_idle_bg: Color::Rgb(128, 203, 196), - operating_chat_fg: Color::Rgb(38, 50, 56), - operating_chat_bg: Color::Rgb(130, 170, 255), - operating_code_fg: Color::Rgb(38, 50, 56), - operating_code_bg: Color::Rgb(199, 146, 234), - } -} - -/// Material Light theme -fn material_light() -> Theme { - Theme { - name: "material-light".to_string(), - text: Color::Rgb(33, 33, 33), - background: Color::Rgb(236, 239, 241), - focused_panel_border: Color::Rgb(0, 150, 136), - unfocused_panel_border: Color::Rgb(176, 190, 197), - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(68, 138, 255), - assistant_message_role: Color::Rgb(124, 77, 255), - tool_output: Color::Rgb(144, 164, 174), - thinking_panel_title: Color::Rgb(245, 124, 0), - command_bar_background: Color::Rgb(255, 255, 255), - status_background: Color::Rgb(255, 255, 255), - mode_normal: Color::Rgb(68, 138, 255), - mode_editing: Color::Rgb(56, 142, 60), - mode_model_selection: Color::Rgb(245, 124, 0), - mode_provider_selection: Color::Rgb(0, 150, 136), - mode_help: Color::Rgb(124, 77, 255), - mode_visual: Color::Rgb(211, 47, 47), - mode_command: Color::Rgb(245, 124, 0), - selection_bg: Color::Rgb(176, 190, 197), - selection_fg: Color::Rgb(33, 33, 33), - cursor: Color::Rgb(194, 24, 91), - code_block_background: Color::Rgb(248, 249, 250), - code_block_border: Color::Rgb(0, 150, 136), - code_block_text: Color::Rgb(33, 33, 33), - code_block_keyword: Color::Rgb(245, 124, 0), - code_block_string: Color::Rgb(56, 142, 60), - code_block_comment: Color::Rgb(144, 164, 174), - placeholder: Color::Rgb(144, 164, 174), - error: Color::Rgb(211, 47, 47), - info: Color::Rgb(56, 142, 60), - agent_thought: Color::Rgb(68, 138, 255), - agent_action: Color::Rgb(245, 124, 0), - agent_action_input: Color::Rgb(124, 77, 255), - agent_observation: Color::Rgb(56, 142, 60), - agent_final_answer: Color::Rgb(211, 47, 47), - agent_badge_running_fg: Color::Named(NamedColor::White), - agent_badge_running_bg: Color::Rgb(245, 124, 0), - agent_badge_idle_fg: Color::Named(NamedColor::White), - agent_badge_idle_bg: Color::Rgb(0, 150, 136), - operating_chat_fg: Color::Named(NamedColor::White), - operating_chat_bg: Color::Rgb(68, 138, 255), - operating_code_fg: Color::Named(NamedColor::White), - operating_code_bg: Color::Rgb(124, 77, 255), - } -} - -/// Grayscale high-contrast theme -fn grayscale_high_contrast() -> Theme { - Theme { - name: "grayscale_high_contrast".to_string(), - text: Color::Rgb(247, 247, 247), - background: Color::Named(NamedColor::Black), - focused_panel_border: Color::Named(NamedColor::White), - unfocused_panel_border: Color::Rgb(76, 76, 76), - focus_beacon_fg: Theme::default_focus_beacon_fg(), - focus_beacon_bg: Theme::default_focus_beacon_bg(), - unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(), - pane_header_active: Theme::default_pane_header_active(), - pane_header_inactive: Theme::default_pane_header_inactive(), - pane_hint_text: Theme::default_pane_hint_text(), - user_message_role: Color::Rgb(240, 240, 240), - assistant_message_role: Color::Rgb(214, 214, 214), - tool_output: Color::Rgb(189, 189, 189), - thinking_panel_title: Color::Rgb(224, 224, 224), - command_bar_background: Color::Named(NamedColor::Black), - status_background: Color::Rgb(15, 15, 15), - mode_normal: Color::Named(NamedColor::White), - mode_editing: Color::Rgb(230, 230, 230), - mode_model_selection: Color::Rgb(204, 204, 204), - mode_provider_selection: Color::Rgb(179, 179, 179), - mode_help: Color::Rgb(153, 153, 153), - mode_visual: Color::Rgb(242, 242, 242), - mode_command: Color::Rgb(208, 208, 208), - selection_bg: Color::Rgb(240, 240, 240), - selection_fg: Color::Named(NamedColor::Black), - cursor: Color::Named(NamedColor::White), - code_block_background: Color::Rgb(15, 15, 15), - code_block_border: Color::Named(NamedColor::White), - code_block_text: Color::Rgb(247, 247, 247), - code_block_keyword: Color::Rgb(204, 204, 204), - code_block_string: Color::Rgb(214, 214, 214), - code_block_comment: Color::Rgb(122, 122, 122), - placeholder: Color::Rgb(122, 122, 122), - error: Color::Named(NamedColor::White), - info: Color::Rgb(200, 200, 200), - agent_thought: Color::Rgb(230, 230, 230), - agent_action: Color::Rgb(204, 204, 204), - agent_action_input: Color::Rgb(176, 176, 176), - agent_observation: Color::Rgb(153, 153, 153), - agent_final_answer: Color::Named(NamedColor::White), - agent_badge_running_fg: Color::Named(NamedColor::Black), - agent_badge_running_bg: Color::Rgb(247, 247, 247), - agent_badge_idle_fg: Color::Named(NamedColor::Black), - agent_badge_idle_bg: Color::Rgb(189, 189, 189), - operating_chat_fg: Color::Named(NamedColor::Black), - operating_chat_bg: Color::Rgb(242, 242, 242), - operating_code_fg: Color::Named(NamedColor::Black), - operating_code_bg: Color::Rgb(191, 191, 191), - } -} - -// Helper functions for color serialization/deserialization - -fn deserialize_color<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let s = String::deserialize(deserializer)?; - parse_color(&s).map_err(serde::de::Error::custom) -} - -fn serialize_color(color: &Color, serializer: S) -> Result -where - S: serde::Serializer, -{ - let s = color_to_string(color); - serializer.serialize_str(&s) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_color_parsing() { - assert!(matches!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0)))); - assert!(matches!( - parse_color("red"), - Ok(Color::Named(NamedColor::Red)) - )); - assert!(matches!( - parse_color("lightblue"), - Ok(Color::Named(NamedColor::LightBlue)) - )); - } - - #[test] - fn test_built_in_themes() { - let themes = built_in_themes(); - assert!(themes.contains_key("default_dark")); - assert!(themes.contains_key("gruvbox")); - assert!(themes.contains_key("dracula")); - assert!(themes.contains_key("grayscale-high-contrast")); - } -} diff --git a/crates/providers/experimental/README.md b/crates/providers/experimental/README.md deleted file mode 100644 index e28d500..0000000 --- a/crates/providers/experimental/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Experimental Providers - -This directory collects non-workspace placeholder crates for potential -third-party providers. The code under the following folders is not yet -implemented and is kept out of the default Cargo workspace to avoid -confusion: - -- `openai` -- `anthropic` -- `gemini` - -If you want to explore or contribute to these providers, start by reading -the `README.md` inside each crate for the current status and ideas. diff --git a/crates/providers/experimental/anthropic/README.md b/crates/providers/experimental/anthropic/README.md deleted file mode 100644 index 6a16773..0000000 --- a/crates/providers/experimental/anthropic/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Owlen Anthropic - -This crate is a placeholder for a future `owlen-core::Provider` implementation for the Anthropic (Claude) API. - -This provider is not yet implemented. Contributions are welcome! diff --git a/crates/providers/experimental/gemini/README.md b/crates/providers/experimental/gemini/README.md deleted file mode 100644 index 901060a..0000000 --- a/crates/providers/experimental/gemini/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Owlen Gemini - -This crate is a placeholder for a future `owlen-core::Provider` implementation for the Google Gemini API. - -This provider is not yet implemented. Contributions are welcome! diff --git a/crates/providers/experimental/openai/README.md b/crates/providers/experimental/openai/README.md deleted file mode 100644 index d4b4ddd..0000000 --- a/crates/providers/experimental/openai/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Owlen OpenAI - -This crate is a placeholder for a future `owlen-core::Provider` implementation for the OpenAI API. - -This provider is not yet implemented. Contributions are welcome! diff --git a/docs/CHANGELOG_v1.0.md b/docs/CHANGELOG_v1.0.md deleted file mode 100644 index 45ff35c..0000000 --- a/docs/CHANGELOG_v1.0.md +++ /dev/null @@ -1,180 +0,0 @@ -# Changelog for v1.0.0 - MCP-Only Architecture - -## Summary - -Version 1.0.0 marks the completion of the MCP-only architecture migration, removing all legacy code paths and fully embracing the Model Context Protocol for all LLM interactions and tool executions. - -## Breaking Changes - -### 1. MCP mode defaults to remote-preferred (legacy retained) - -**What changed:** -- The `[mcp]` section in `config.toml` keeps a `mode` setting but now defaults to `remote_preferred`. -- Legacy values such as `"legacy"` map to the `local_only` runtime and emit a warning instead of failing. -- New toggles (`allow_fallback`, `warn_on_legacy`) give administrators explicit control over graceful degradation. - -**Migration:** -```toml -[mcp] -mode = "remote_preferred" -allow_fallback = true -warn_on_legacy = true -``` - -To opt out of remote MCP servers temporarily: - -```toml -[mcp] -mode = "local_only" # or "legacy" for backwards compatibility -``` - -**Code changes:** -- `crates/owlen-core/src/config.rs`: Reintroduced `McpMode` with compatibility aliases and new settings. -- `crates/owlen-core/src/mcp/factory.rs`: Respects the configured mode, including strict remote-only and local-only paths. -- `crates/owlen-cli/src/main.rs`: Chooses between remote MCP providers and the direct Ollama provider based on the mode. - -### 2. Updated MCP Client Factory - -**What changed:** -- `McpClientFactory::create()` now enforces the configured mode (`remote_only`, `remote_preferred`, `local_only`, or `legacy`). -- Helpful configuration errors are surfaced when remote-only mode lacks servers or fallback is disabled. -- CLI users in `local_only`/`legacy` mode receive the direct Ollama provider instead of a failing MCP stub. - -**Before:** -```rust -match self.config.mcp.mode { - McpMode::Legacy => { /* use local */ }, - McpMode::Enabled => { /* use remote or fallback */ }, -} -``` - -**After:** -```rust -match self.config.mcp.mode { - McpMode::RemoteOnly => start_remote()?, - McpMode::RemotePreferred => try_remote_or_fallback()?, - McpMode::LocalOnly | McpMode::Legacy => use_local(), - McpMode::Disabled => bail!("unsupported"), -} -``` - -## New Features - -### Test Infrastructure - -Added comprehensive mock implementations for testing: - -1. **MockProvider** (`crates/owlen-core/src/provider.rs`) - - Located in `provider::test_utils` module - - Provides a simple provider for unit tests - - Implements all required `Provider` trait methods - -2. **MockMcpClient** (`crates/owlen-core/src/mcp.rs`) - - Located in `mcp::test_utils` module - - Provides a simple MCP client for unit tests - - Returns mock tool descriptors and responses - -### Documentation - -1. **Migration Guide** (`docs/migration-guide.md`) - - Comprehensive guide for migrating from v0.x to v1.0 - - Step-by-step configuration update instructions - - Common issues and troubleshooting - - Rollback procedures if needed - -2. **Updated Configuration Reference** - - Documented the new `remote_preferred` default and fallback controls - - Clarified MCP server configuration with remote-only expectations - - Added examples for local and cloud Ollama usage - -## Bug Fixes - -- Fixed test compilation errors due to missing mock implementations -- Resolved ambiguous glob re-export warnings (non-critical, test-only) - -## Internal Changes - -### Configuration System - -- `McpSettings` gained `mode`, `allow_fallback`, and `warn_on_legacy` knobs. -- `McpMode` enum restored with explicit aliases for historical values. -- Default configuration now prefers remote servers but still works out-of-the-box with local tooling. - -### MCP Factory - -- Simplified factory logic by removing mode branching -- Improved fallback behavior with better error messages -- Test renamed to reflect new behavior: `test_factory_creates_local_client_when_no_servers_configured` - -## Performance - -No performance regressions expected. The MCP architecture may actually improve performance by: -- Removing unnecessary mode checks -- Streamlining the client creation process -- Better error handling reduces retry overhead - -## Compatibility - -### Backwards Compatibility - -- Existing `mode = "legacy"` configs keep working (now mapped to `local_only`) but trigger a startup warning. -- Users who relied on remote-only behaviour should set `mode = "remote_only"` explicitly. - -### Forward Compatibility - -The `McpSettings` struct now provides a stable surface to grow additional MCP-specific options such as: -- Connection pooling strategies -- Remote health-check cadence -- Adaptive retry controls - -## Testing - -All tests passing: -``` -test result: ok. 29 passed; 0 failed; 0 ignored -``` - -Key test areas: -- Agent ReAct pattern parsing -- MCP client factory creation -- Configuration loading and validation -- Mode-based tool filtering -- Permission and consent handling - -## Upgrade Instructions - -See [Migration Guide](migration-guide.md) for detailed instructions. - -**Quick upgrade:** - -1. Update your `~/.config/owlen/config.toml`: - ```bash - # Remove the 'mode' line from [mcp] section - sed -i '/^mode = /d' ~/.config/owlen/config.toml - ``` - -2. Rebuild Owlen: - ```bash - cargo build --release - ``` - -3. Test with a simple query: - ```bash - owlen - ``` - -## Known Issues - -1. **Warning about ambiguous glob re-exports** - Non-critical, only affects test builds -2. **First inference may be slow** - Ollama loads models on first use (expected behavior) -3. **Cloud model 404 errors** - Ensure model names match Ollama Cloud's naming (remove `-cloud` suffix from model names) - -## Contributors - -This release completes the Phase 10 migration plan documented in `.agents/new_phases.md`. - -## Related Issues - -- Closes: Legacy mode removal -- Implements: Phase 10 cleanup and production polish -- References: MCP architecture migration phases 1-10 diff --git a/docs/adding-providers.md b/docs/adding-providers.md deleted file mode 100644 index 4bde82d..0000000 --- a/docs/adding-providers.md +++ /dev/null @@ -1,62 +0,0 @@ -# Adding a Provider to Owlen - -This guide complements `docs/provider-implementation.md` with a practical checklist for wiring a new model backend into the Phase 10 architecture. - -## 1. Define the Provider Type - -Providers live in their own crate (for example `owlen-providers`). Create a module that implements the `owlen_core::provider::ModelProvider` trait: - -```rust -pub struct MyProvider { - client: MyHttpClient, - metadata: ProviderMetadata, -} - -#[async_trait] -impl ModelProvider for MyProvider { - fn metadata(&self) -> &ProviderMetadata { &self.metadata } - async fn health_check(&self) -> Result { ... } - async fn list_models(&self) -> Result> { ... } - async fn generate_stream(&self, request: GenerateRequest) -> Result { ... } -} -``` - -Set `ProviderMetadata::provider_type` to `ProviderType::Local` or `ProviderType::Cloud` so the TUI can label it correctly. - -## 2. Register with `ProviderManager` - -`ProviderManager` owns provider instances and tracks their health. In your startup code (usually `owlen-cli` or an MCP server), construct the provider and register it: - -```rust -let manager = ProviderManager::new(config); -manager.register_provider(Arc::new(MyProvider::new(config)?.into())).await; -``` - -The manager caches `ProviderStatus` values so the TUI can surface availability in the picker and background worker events. - -## 3. Expose Through MCP (Optional) - -For providers that should run out-of-process, implement an MCP server (`owlen-mcp-llm-server` demonstrates the pattern). The TUI uses `RemoteMcpClient`, so exposing `generate_text` keeps the UI completely decoupled from provider details. - -## 4. Add Tests - -Commit 13 introduced integration tests in `crates/owlen-providers/tests`. Follow this pattern to exercise: - -- registration with `ProviderManager` -- model aggregation across providers -- routing of `generate` requests -- provider status transitions when generation succeeds or fails - -In-memory mocks are enough; the goal is to protect the trait contract and the manager’s health cache. - -## 5. Document Configuration - -Update `docs/configuration.md` and the default `config.toml` snippet so users can enable the new provider. Include environment variables, auth requirements, or special flags. - -## 6. Update User-Facing Docs - -- Add a short entry to the feature list in `README.md`. -- Mention the new provider in `CHANGELOG.md` under the “Added” section. -- If the provider requires troubleshooting steps, append them to `docs/troubleshooting.md`. - -Following these steps keeps the provider lifecycle consistent with Owlen’s multi-provider architecture: providers register once, the manager handles orchestration, and the TUI reacts via message-driven updates. diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index e5bbf21..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,178 +0,0 @@ -# Owlen Architecture - -This document provides a high-level overview of the Owlen architecture. Its purpose is to help developers understand how the different parts of the application fit together. - -## Core Concepts - -The architecture is designed to be modular and extensible, centered around a few key concepts: - -- **Provider Manager**: Coordinates multiple `ModelProvider` implementations, aggregates model metadata, and caches health status for the UI. -- **Providers**: Concrete backends (Ollama Local, Ollama Cloud, future providers) accessed either directly or through MCP servers. -- **Session**: Manages the conversation history and state. -- **TUI**: The terminal user interface, built with `ratatui`. -- **Events**: A system for handling user input and other events. - -## Component Interaction - -A simplified diagram of how components interact: - -``` -[User Input] -> [Event Loop] -> [Message Handler] -> [Session Controller] -> [Provider Manager] -> [Provider] - ^ | - | v -[TUI Renderer] <- [AppMessage Stream] <- [Background Worker] <--------------- [Provider Health] -``` - -1. **User Input**: The user interacts with the TUI, generating events (e.g., key presses). -2. **Event Loop**: The non-blocking event loop in `owlen-tui` bundles raw input, async session events, and background health updates into `AppMessage` events. -3. **Message Handler**: `App::handle_message` centralises dispatch, updating runtime state (chat, model picker, provider indicators) before the UI redraw. -4. **Session Controller**: Prompt events create `GenerateRequest`s that flow through `ProviderManager::generate` to the designated provider. -5. **Provider**: The provider formats requests for its API and streams back `GenerateChunk`s. -6. **Provider Manager**: Tracks health while streaming; errors mark a provider unavailable so background workers and the model picker reflect the state. -7. **Background Worker**: A periodic task runs health checks and emits status updates as `AppMessage::ProviderStatus` events. -8. **TUI Renderer**: The response is processed, the session state is updated, and the TUI is re-rendered to display the new information. - -## Crate Breakdown - -- `owlen-core`: Defines the `LlmProvider` abstraction, routing, configuration, session state, encryption, and the MCP client layer. This crate is UI-agnostic and must not depend on concrete providers, terminals, or blocking I/O. -- `owlen-tui`: Hosts all terminal UI behaviour (event loop, rendering, input modes) while delegating business logic and provider access back to `owlen-core`. -- `owlen-cli`: Small entry point that parses command-line options, resolves configuration, selects providers, and launches either the TUI or headless agent flows by calling into `owlen-core`. -- `owlen-mcp-llm-server`: Runs concrete providers (e.g., Ollama Local, Ollama Cloud) behind an MCP boundary, exposing them as `generate_text` tools. This crate owns provider-specific wiring and process sandboxing. -- `owlen-mcp-server`: Generic MCP server for file operations, resource projection, and other non-LLM tools. -- `owlen-providers`: Houses concrete provider adapters (today: Ollama local + cloud) that the MCP servers embed. - -### Boundary Guidelines - -- **owlen-core**: The dependency ceiling for most crates. Keep it free of terminal logic, CLIs, or provider-specific HTTP clients. New features should expose traits or data types here and let other crates supply concrete implementations. -- **owlen-cli**: Only orchestrates startup/shutdown. Avoid adding business logic; when a new command needs behaviour, implement it in `owlen-core` or another library crate and invoke it from the CLI. -- **owlen-mcp-llm-server**: The only crate that should directly talk to Ollama (or other provider processes). TUI/CLI code communicates with providers exclusively through MCP clients in `owlen-core`. - -## Provider Boundaries & MCP Topology - -Owlen’s runtime is intentionally layered so that user interfaces never couple to provider-specific code. The flow can be visualised as: - -``` -[owlen-tui] / [owlen-cli] - │ - │ chat + model requests - ▼ -[owlen-core::ProviderManager] ──> Arc - │ ▲ - │ │ implements `ModelProvider` - ▼ │ -[owlen-core::mcp::RemoteMcpClient] ─────┘ - │ (JSON-RPC over stdio) - ▼ -┌───────────────────────────────────────────────────────────┐ -│ MCP Process Boundary (spawned per provider) │ -│ │ -│ crates/mcp/llm-server ──> owlen-providers::ollama::* │ -│ crates/mcp/server ──> filesystem & workspace tools │ -│ crates/mcp/prompt-server ─> template rendering helpers │ -└───────────────────────────────────────────────────────────┘ -``` - -- **ProviderManager (owlen-core)** keeps the registry of `ModelProvider` implementations, merges model catalogues, and caches health. Local Ollama and Cloud Ollama appear as separate providers whose metadata is merged for the UI. -- **RemoteMcpClient (owlen-core)** is the default `ModelProvider`. It implements both the MCP client traits and the `ModelProvider` interface, allowing it to bridge chat streams back into the ProviderManager without exposing transport details. -- **MCP servers (crates/mcp/\*)** are short-lived binaries with narrowly scoped responsibilities: - - `crates/mcp/llm-server` wraps `owlen-providers::ollama` backends and exposes `generate_text` / `list_models`. - - `crates/mcp/server` offers tool calls (file reads/writes, search). - - `crates/mcp/prompt-server` renders prompt templates. -- **owlen-providers** contains the actual provider adapters (Ollama local & cloud today). MCP servers embed these adapters directly; nothing else should reach into them. - -### Health & Model Discovery Flow - -1. Frontends call `ProviderManager::list_all_models()`. The manager fans out health checks to each registered provider (including the MCP client) and collates their models into a single list tagged with scope (`Local`, `Cloud`, etc.). -2. The TUI model picker (`owlen-tui/src/widgets/model_picker.rs`) reads those annotated entries to drive filters like **Local**, **Cloud**, and **Available**. -3. When the user kicks off a chat, the TUI emits a request that flows through `Session::send_message`, which delegates to `ProviderManager::generate`. The selected provider (usually `RemoteMcpClient`) streams chunks back across the MCP transport and the manager updates health status based on success or failure. -4. Tool invocations travel the same transport: the MCP client sends tool calls to `crates/mcp/server`, and responses surface as consent prompts or streamed completions in the UI. - -## MCP Architecture (Phase 10) - -As of Phase 10, OWLEN uses a **MCP-only architecture** where all LLM interactions go through the Model Context Protocol: - -``` -[TUI/CLI] -> [RemoteMcpClient] -> [MCP LLM Server] -> [Ollama Provider] -> [Ollama API] -``` - -### Benefits of MCP Architecture - -1. **Separation of Concerns**: The TUI/CLI never directly instantiates provider implementations. -2. **Process Isolation**: LLM interactions run in a separate process, improving stability. -3. **Extensibility**: New providers can be added by implementing MCP servers. -4. **Multi-Transport**: Supports STDIO, HTTP, and WebSocket transports. -5. **Tool Integration**: MCP servers can expose tools (file operations, web search, etc.) to the LLM. - -### MCP Communication Flow - -1. **Client Creation**: `RemoteMcpClient::new()` spawns an MCP server binary via STDIO. -2. **Initialization**: Client sends `initialize` request to establish protocol version. -3. **Tool Discovery**: Client calls `tools/list` to discover available LLM operations. -4. **Chat Requests**: Client calls the `generate_text` tool with chat parameters. -5. **Streaming**: Server sends progress notifications during generation, then final response. -6. **Response Handling**: Client skips notifications and returns the final text to the caller. - -### Cloud Provider Support - -For Ollama Cloud providers, the MCP server accepts an `OLLAMA_URL` environment variable: - -```rust -let env_vars = HashMap::from([ - ("OLLAMA_URL".to_string(), "https://cloud-provider-url".to_string()) -]); -let config = McpServerConfig { - command: "path/to/owlen-mcp-llm-server", - env: env_vars, - transport: "stdio", - ... -}; -let client = RemoteMcpClient::new_with_config(&config)?; -``` - -## Vim Mode State Machine - -The TUI follows a Vim-inspired modal workflow. Maintaining the transitions keeps keyboard handling predictable: - -- **Normal → Insert**: triggered by keys such as `i`, `a`, or `o`; pressing `Esc` returns to Normal. -- **Normal → Visual**: `v` enters visual selection; `Esc` or completing a selection returns to Normal. -- **Normal → Command**: `:` opens command mode; executing a command or cancelling with `Esc` returns to Normal. -- **Normal → Auxiliary modes**: `?` (help), `:provider`, `:model`, and similar commands open transient overlays that always exit back to Normal once dismissed. -- **Insert/Visual/Command → Normal**: pressing `Esc` always restores the neutral state. - -The status line shows the active mode (for example, “Normal mode • Press F1 for help”), which doubles as a quick regression check during manual testing. - -## Session Management - -The session management system is responsible for tracking the state of a conversation. The two main structs are: - -- **`Conversation`**: Found in `owlen-core`, this struct holds the messages of a single conversation, the model being used, and other metadata. It is a simple data container. -- **`SessionController`**: This is the high-level controller that manages the active conversation. It handles: - - Storing and retrieving conversation history via the `ConversationManager`. - - Managing the context that is sent to the LLM provider. - - Switching between different models by selecting a provider ID managed by `ProviderManager`. - - Sending requests to the provider and handling the responses (both streaming and complete). - -When a user sends a message, the `SessionController` adds the message to the current `Conversation`, sends the updated message list to the `Provider`, and then adds the provider's response to the `Conversation`. - -## Event Flow - -The event flow is managed by the `EventHandler` in `owlen-tui`. It operates in a loop, waiting for events and dispatching them to the active application (`ChatApp` or `CodeApp`). - -1. **Event Source**: Events are primarily generated by `crossterm` from user keyboard input. Asynchronous events, like responses from a `Provider`, are also fed into the event system via a `tokio::mpsc` channel. -2. **`EventHandler::next()`**: The main application loop calls this method to wait for the next event. -3. **Event Enum**: Events are defined in the `owlen_tui::events::Event` enum. This includes `Key` events, `Tick` events (for UI updates), and `Message` events (for async provider data). -4. **Dispatch**: The application's `run` method matches on the `Event` type and calls the appropriate handler function (e.g., `dispatch_key_event`). -5. **State Update**: The handler function updates the application state based on the event. For example, a key press might change the `InputMode` or modify the text in the input buffer. -6. **Re-render**: After the state is updated, the UI is re-rendered to reflect the changes. - -## TUI Rendering Pipeline - -The TUI is rendered on each iteration of the main application loop in `owlen-tui`. The process is as follows: - -1. **`tui.draw()`**: The main loop calls this method, passing the current application state. -2. **`Terminal::draw()`**: This method, from `ratatui`, takes a closure that receives a `Frame`. -3. **UI Composition**: Inside the closure, the UI is built by composing `ratatui` widgets. The root UI is defined in `owlen_tui::ui::render`, which builds the main layout and calls other functions to render specific components (like the chat panel, input box, etc.). -4. **State-Driven Rendering**: Each rendering function takes the current application state as an argument. It uses this state to decide what and how to render. For example, the border color of a panel might change if it is focused. -5. **Buffer and Diff**: `ratatui` does not draw directly to the terminal. Instead, it renders the widgets to an in-memory buffer. It then compares this buffer to the previous buffer and only sends the necessary changes to the terminal. This is highly efficient and prevents flickering. - -The command palette and other modal helpers expose lightweight state structs in `owlen_tui::state`. These components keep business logic (suggestion filtering, selection state, etc.) independent from rendering, which in turn makes them straightforward to unit test. The ongoing migration of more features into the `Model–View–Update` core is documented in [`docs/tui-mvu-migration.md`](tui-mvu-migration.md). diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index 166881a..0000000 --- a/docs/configuration.md +++ /dev/null @@ -1,236 +0,0 @@ -# Owlen Configuration - -Owlen uses a TOML file for configuration, allowing you to customize its behavior to your liking. This document details all the available options. - -## File Location - -Owlen resolves the configuration path using the platform-specific config directory: - -| Platform | Location | -|----------|----------| -| Linux | `~/.config/owlen/config.toml` | -| macOS | `~/Library/Application Support/owlen/config.toml` | -| Windows | `%APPDATA%\owlen\config.toml` | - -Use `owlen config init` to scaffold the latest default configuration (pass `--force` to overwrite an existing file), `owlen config path` to print the resolved location, and `owlen config doctor` to migrate or repair legacy files automatically. - -## Configuration Precedence - -Configuration values are resolved in the following order: - -1. **Defaults**: The application has hard-coded default values for all settings. -2. **Configuration File**: Any values set in `config.toml` will override the defaults. -3. **Command-Line Arguments / In-App Changes**: Any settings changed during runtime (e.g., via the `:theme` or `:model` commands) will override the configuration file for the current session. Some of these changes (like theme and model) are automatically saved back to the configuration file. - -Validation runs whenever the configuration is loaded or saved. Expect descriptive `Configuration error` messages if, for example, `remote_only` mode is set without any `[[mcp_servers]]` entries. - ---- - -## General Settings (`[general]`) - -These settings control the core behavior of the application. - -- `default_provider` (string, default: `"ollama"`) - The name of the provider to use by default. - -- `default_model` (string, optional, default: `"llama3.2:latest"`) - The default model to use for new conversations. - -- `enable_streaming` (boolean, default: `true`) - Whether to stream responses from the provider by default. - -- `project_context_file` (string, optional, default: `"OWLEN.md"`) - Path to a file whose content will be automatically injected as a system prompt. This is useful for providing project-specific context. - -- `model_cache_ttl_secs` (integer, default: `60`) - Time-to-live in seconds for the cached list of available models. - -## UI Settings (`[ui]`) - -These settings customize the look and feel of the terminal interface. - -- `theme` (string, default: `"default_dark"`) - The name of the theme to use. See the [Theming Guide](https://github.com/Owlibou/owlen/blob/main/themes/README.md) for available themes. - -- `word_wrap` (boolean, default: `true`) - Whether to wrap long lines in the chat view. - -- `max_history_lines` (integer, default: `2000`) - The maximum number of lines to keep in the scrollback buffer for the chat history. - -- `role_label` (string, default: `"above"`) - Controls how sender labels are rendered next to messages. Valid values are `"above"` (label on its own line), `"inline"` (label shares the first line of the message), and `"none"` (no label). - -- `wrap_column` (integer, default: `100`) - The column at which to wrap text if `word_wrap` is enabled. - -- `input_max_rows` (integer, default: `5`) - The maximum number of rows the input panel will expand to before it starts scrolling internally. Increase this value if you prefer to see more of long prompts while editing. - -- `scrollback_lines` (integer, default: `2000`) - The maximum number of rendered lines the chat view keeps in memory. Set to `0` to disable trimming entirely if you prefer unlimited history. - -- `syntax_highlighting` (boolean, default: `false`) - Enables lightweight syntax highlighting inside fenced code blocks when the terminal supports 256-color output. - -- `keymap_profile` (string, optional) - Set to `"vim"` or `"emacs"` to pick a built-in keymap profile. When omitted the default Vim bindings are used. Runtime changes triggered via `:keymap ...` are persisted by updating this field. - -- `keymap_path` (string, optional) - Absolute path to a custom keymap definition. When present it overrides `keymap_profile`. See `crates/owlen-tui/keymap.toml` or `crates/owlen-tui/keymap_emacs.toml` for the expected TOML structure. - -## Storage Settings (`[storage]`) - -These settings control how conversations are saved and loaded. - -- `conversation_dir` (string, optional, default: platform-specific) - The directory where conversation sessions are saved. If not set, a default directory is used: - - **Linux**: `~/.local/share/owlen/sessions` - - **Windows**: `%APPDATA%\owlen\sessions` - - **macOS**: `~/Library/Application Support/owlen/sessions` - -- `auto_save_sessions` (boolean, default: `true`) - Whether to automatically save the session when the application exits. - -- `max_saved_sessions` (integer, default: `25`) - The maximum number of saved sessions to keep. - -- `session_timeout_minutes` (integer, default: `120`) - The number of minutes of inactivity before a session is considered for auto-saving as a new session. - -- `generate_descriptions` (boolean, default: `true`) - Whether to automatically generate a short summary of a conversation when saving it. - -## Input Settings (`[input]`) - -These settings control the behavior of the text input area. - -- `multiline` (boolean, default: `true`) - Whether to allow multi-line input. - -- `history_size` (integer, default: `100`) - The number of sent messages to keep in the input history (accessible with `Ctrl-Up/Down`). - -- `tab_width` (integer, default: `4`) - The number of spaces to insert when the `Tab` key is pressed. - -- `confirm_send` (boolean, default: `false`) - If true, requires an additional confirmation before sending a message. - -## Provider Settings (`[providers]`) - -This section contains a table for each provider you want to configure. Owlen now ships with four entries pre-populated—`ollama_local`, `ollama_cloud`, `openai`, and `anthropic`. Switch between them by updating `general.default_provider`. - -```toml -[providers.ollama_local] -enabled = true -provider_type = "ollama" -base_url = "http://localhost:11434" -list_ttl_secs = 60 -default_context_window = 8192 - -[providers.ollama_cloud] -enabled = false -provider_type = "ollama_cloud" -base_url = "https://ollama.com" -api_key_env = "OLLAMA_API_KEY" -hourly_quota_tokens = 50000 -weekly_quota_tokens = 250000 -list_ttl_secs = 60 -default_context_window = 8192 - -[providers.openai] -enabled = false -provider_type = "openai" -base_url = "https://api.openai.com/v1" -api_key_env = "OPENAI_API_KEY" - -[providers.anthropic] -enabled = false -provider_type = "anthropic" -base_url = "https://api.anthropic.com/v1" -api_key_env = "ANTHROPIC_API_KEY" -``` - -- `enabled` (boolean, default: `true`) - Whether the provider should be considered when refreshing models or issuing requests. - -- `provider_type` (string, required) - Identifies which implementation to use. Local Ollama instances use `"ollama"`; the hosted service uses `"ollama_cloud"`. Third-party providers use their own identifiers (`"openai"`, `"anthropic"`, ...). - -- `base_url` (string, optional) - The base URL of the provider's API. - -- `api_key` / `api_key_env` (string, optional) - Authentication material. Prefer `api_key_env` to reference an environment variable so secrets remain outside of the config file. - -- `list_ttl_secs` (integer, default: `60`) - Time-to-live for the cached model list used by the picker. Increase it to reduce background traffic or decrease it if you rotate models frequently. - -- `default_context_window` (integer, optional) - Expected maximum prompt length (tokens) for the provider. Owlen uses this to render the context usage gauge and warn when you approach the limit. - -- `hourly_quota_tokens` / `weekly_quota_tokens` (integer, optional) - Soft limits that drive the cloud usage gauge and `:limits` readout. Owlen tracks actual usage locally and compares it to these thresholds to raise 80% / 95% toasts. - -- `extra` (table, optional) - Any additional, provider-specific parameters can be added here. - -### Using Ollama Cloud - -Owlen now separates the local daemon and the hosted API into two providers. Enable `ollama_cloud` once you have credentials, while keeping `ollama_local` available for on-device workloads. A minimal configuration looks like this: - -```toml -[general] -default_provider = "ollama_local" - -[providers.ollama_local] -enabled = true -base_url = "http://localhost:11434" - -[providers.ollama_cloud] -enabled = true -base_url = "https://ollama.com" -api_key_env = "OLLAMA_API_KEY" -hourly_quota_tokens = 50000 -weekly_quota_tokens = 250000 -list_ttl_secs = 60 -default_context_window = 8192 -``` - -## Repository Automation (GitHub) - -The `owlen repo` CLI commands and the matching `:repo` TUI shortcuts can fetch pull requests directly from GitHub. Authentication is driven primarily through environment variables: - -- Set `GITHUB_TOKEN` to a personal access token with at least `repo:read` scope. The CLI reads this variable automatically; override it with `--token` or pick a different environment variable with `--token-env NAME`. -- GitHub Enterprise instances are supported via the `--api-endpoint` flag (for example, `https://github.my-company.com/api/v3`). The same flag is respected when you run `owlen repo review` or when the TUI invokes the automation helpers. - -When no token is available, the commands fall back to analysing local diffs only. - -Key points to keep in mind: - -- **Base URL normalisation** – Owlen accepts `https://ollama.com`, `https://ollama.com/api`, `https://ollama.com/v1`, and the legacy `https://api.ollama.com`, canonicalising them to the correct HTTPS host. Local deployments get the same treatment for `http://localhost:11434`, `/api`, or `/v1`. You only need to customise `base_url` when the service is proxied elsewhere. -- **Credential precedence** – The resolver prefers an inline `api_key` first, then `api_key_env`, then the process environment in the order `OLLAMA_API_KEY`, `OLLAMA_CLOUD_API_KEY`, and `OWLEN_OLLAMA_CLOUD_API_KEY`. Set exactly one source to avoid surprises. When the `ollama` (local) provider is selected, any API key is ignored. -- **Transport security** – Hosted requests must use HTTPS unless the development-only flag `OWLEN_ALLOW_INSECURE_CLOUD=1` is set. Leave this unset in production to avoid leaking credentials over cleartext channels. -- **Web search endpoint** – By default Owlen calls `/api/web_search`. If your deployment exposes the tool at `/v1/web/search` (Codex CLI style) or any other path, set `providers.ollama_cloud.extra.web_search_endpoint`. The session layer reuses the same normalisation logic, so whatever URL the provider accepts will also be used for tool consent checks. - -The quota fields are optional and purely informational—they are never sent to the provider. Owlen uses them to display hourly/weekly token usage in the chat header, emit pre-limit toasts at 80% and 95%, and power the `:limits` command. Adjust the numbers to reflect the soft limits on your account or remove the keys altogether if you do not want usage tracking. - -If your deployment exposes the web search endpoint under a different path, set `web_search_endpoint` in the same table. The default (`/api/web_search`) matches the Ollama Cloud REST API documented in the web retrieval guide. Tools must be registered with spec-compliant identifiers (for example `web_search`, `browser_fetch`); dotted names are rejected.citeturn11search0 - -Toggle the feature at runtime with `:web on` / `:web off` from the TUI or `owlen providers web --enable/--disable` on the CLI; both commands persist the change back to `config.toml`. See `docs/mcp-reference.md` for the full connector bundle, installation commands, and health checks.citeturn1search6turn1search8 - -> **Tip:** If the official `ollama signin` flow fails on Linux v0.12.3, follow the [Linux Ollama sign-in workaround](#linux-ollama-sign-in-workaround-v0123) in the troubleshooting guide to copy keys from a working machine or register them manually. - -### Managing cloud credentials via CLI - -Owlen now ships with an interactive helper for Ollama Cloud: - -```bash -owlen cloud setup --api-key # Configure your API key (uses stored value when omitted) -owlen cloud status # Verify authentication/latency -owlen cloud models # List the hosted models your account can access -owlen cloud logout # Forget the stored API key -``` - -When `privacy.encrypt_local_data = true`, the API key is written to Owlen's encrypted credential vault instead of being persisted in plaintext. The vault key is generated and managed automatically—no passphrase prompts are ever shown—and subsequent invocations hydrate the runtime environment from that secure store. If encryption is disabled, the key is stored under `[providers.ollama_cloud].api_key` as before. diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index dbc4ec6..0000000 --- a/docs/faq.md +++ /dev/null @@ -1,42 +0,0 @@ -# Frequently Asked Questions (FAQ) - -### What is the difference between `owlen` and `owlen-code`? - -- `owlen` is the general-purpose chat client. -- `owlen-code` is an experimental client with a system prompt that is optimized for programming and code-related questions. In the future, it will include more code-specific features like file context and syntax highlighting. - -### How do I use Owlen with a different terminal? - -Owlen is designed to work with most modern terminals that support 256 colors and Unicode. If you experience rendering issues, you might try: - -- **WezTerm**: Excellent cross-platform, GPU-accelerated terminal. -- **Alacritty**: Another fast, GPU-accelerated terminal. -- **Kitty**: A feature-rich terminal emulator. - -If issues persist, please open an issue and let us know what terminal you are using. - -### What is the setup for Windows? - -The Windows build is currently experimental. However, you can install it from source using `cargo` if you have the Rust toolchain installed. - -1. Install Rust from [rustup.rs](https://rustup.rs). -2. Install Git for Windows. -3. Clone the repository: `git clone https://github.com/Owlibou/owlen.git` -4. Install: `cd owlen && cargo install --path crates/owlen-cli` - -Official binary releases for Windows are planned for the future. - -### What is the setup for macOS? - -Similar to Windows, the recommended installation method for macOS is to build from source using `cargo`. - -1. Install the Xcode command-line tools: `xcode-select --install` -2. Install Rust from [rustup.rs](https://rustup.rs). -3. Clone the repository: `git clone https://github.com/Owlibou/owlen.git` -4. Install: `cd owlen && cargo install --path crates/owlen-cli` - -Official binary releases for macOS are planned. - -### I'm getting connection failures to Ollama. - -Please see the [Troubleshooting Guide](troubleshooting.md#connection-failures-to-ollama) for help with this common issue. diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md deleted file mode 100644 index 72e4701..0000000 --- a/docs/mcp-reference.md +++ /dev/null @@ -1,62 +0,0 @@ -# MCP Reference Bundles - -Owlen’s MCP configuration guidance now targets the same canonical toolset shipped by leading desktop and IDE clients, without relying on brand-specific heuristics. This document distills the common naming rules, connector categories, and installation patterns you should follow when wiring those tools into Owlen. - -## Naming Rules - -- Tool identifiers must match the regular expression `^[A-Za-z0-9_-]{1,64}$`.citeturn11search0 -- Avoid dots or spaces in identifiers; use underscores when you need multiword names (`sequential_thinking`, `browser_fetch`). -- When multiple servers expose similarly named tools, qualify the call using `{server}__{tool}` (for example, `filesystem__read` or `browser__request`). - -## Connector Categories - -| Category | Connector | Capability Highlights | Typical Command Snippet | -| --- | --- | --- | --- | -| Core context | `filesystem` | Mount local directories for read/write access during a chat session.citeturn1search6 | `npx -y @modelcontextprotocol/server-filesystem ` | -| Core context | `sequential-thinking` | Structured problem solving with persistent reasoning chains and Memory Bank integration.citeturn1search0turn1search6 | `npx -y @modelcontextprotocol/server-sequential-thinking` | -| Core context | `fetch` | Lightweight HTTP client for REST/JSON retrieval.citeturn1search6 | `npx -y @kazuph/mcp-fetch` | -| Automation | `puppeteer` | Headless browser automation (navigation, screenshots, DOM scripting).citeturn1search5turn1search7 | `npx -y @modelcontextprotocol/server-puppeteer` | -| Automation | `browser-tools` / `devtools` | Deep Chrome DevTools integration for inspection and profiling.citeturn1search6 | `npx -y @modelcontextprotocol/server-browser-tools` | -| Retrieval | `brave-search`, `firecrawl` | API-driven web search and scraping for current information (requires API keys).citeturn1search6turn1search8 | `claude mcp add brave-search …` (replace with `owlen mcp add brave_search …`) | -| Retrieval | `perplexity`, `tavily`, `search1api` | Aggregated search/QA endpoints with structured responses.citeturn1search8 | `owlen mcp add tavily -- npx -y @tavily/mcp-server` | -| Observability | `sentry`, `raygun` | Incident and crash analytics access for production systems.citeturn1search8 | `owlen mcp add sentry -- npx -y @sentry/mcp-server` | -| Productivity | `notion`, `slack`, `stripe`, `zapier`, `google_drive` | Common SaaS integrations for knowledge bases, messaging, payments, automation, and storage.citeturn1search8 | `owlen mcp add notion -- npx -y @notion/mcp-server` | -| Data | `postgresql`, `sqlite`, `qdrant`, `redis`, `tinybird` | Database and vector-store connectors for analytics and retrieval.citeturn1search8 | `owlen mcp add postgresql -- npx -y @modelcontextprotocol/server-postgresql` | -| Execution | `python`, `notebook`, `riza`, `semgrep` | Sandbox code execution, notebook orchestration, security linting.citeturn1search8 | `owlen mcp add python -- npx -y @modelcontextprotocol/server-python` | - -> **Tip:** Replace any vendor-specific CLI (for example, `claude mcp add`) with the upcoming `owlen mcp add` command when working within Owlen. Until that lands, copy the arguments into `config.toml` directly or wrap them in project scripts—the syntax is intentionally mirrored so the migration is mechanical. - -## Installation Workflow - -1. **Plan your bundle.** Start with the core context servers (`filesystem`, `sequential-thinking`, `fetch`, `puppeteer`). Layer in retrieval, observability, productivity, and execution connectors as your project requires. -2. **Add each server under `[mcp_servers]`.** Use spec-compliant names and consider grouping them by category inside `config.toml`. -3. **Qualify tool calls.** When writing prompts or unit tests, address tools using `{server}__{tool}` to avoid collisions once you enable multiple servers that expose similar endpoints. -4. **Document required secrets.** Many SaaS connectors need API keys. Store their environment variables in your preferred secret manager and reference them in the `[mcp_servers..env]` table. -5. **Run health checks.** Each connector listed above includes a health or status endpoint; wire these into your startup scripts so Owlen can surface actionable errors before a chat begins. - -## Next Steps - -- Update `config.toml` with the reference bundle (see `docs/configuration.md`). -- Use `owlen tools audit` to remove or disable any legacy MCP servers that do not meet the naming constraints. -- Track connector-specific onboarding (API keys, OAuth scopes) in team documentation so new contributors can reproduce the setup quickly. - - -## Preset Workflow - -Run these helpers after updating Owlen to align your MCP configuration with the reference bundles: - -```bash -# Install the baseline connectors -owlen tools install standard - -# Extend with retrieval and automation -owlen tools install extended - -# Switch to the full SaaS bundle (remove anything not in the preset) -owlen tools install full --prune - -# Review current configuration against the full preset -owlen tools audit full -``` - -Within the TUI the same presets are available via commands such as `:tools install standard --prune` and `:tools audit full`. diff --git a/docs/migration-guide.md b/docs/migration-guide.md deleted file mode 100644 index f3f560c..0000000 --- a/docs/migration-guide.md +++ /dev/null @@ -1,215 +0,0 @@ -# Migration Guide - -This guide documents breaking changes between versions of Owlen and provides instructions on how to migrate your configuration or usage. - -As Owlen is currently in its alpha phase (pre-v1.0), breaking changes may occur more frequently. We will do our best to document them here. - ---- - -## Migrating from v0.x to v1.0 (MCP-Only Architecture) - -**Version 1.0** marks a major milestone: Owlen has completed its transition to a **MCP-only architecture** (Model Context Protocol). This brings significant improvements in modularity, extensibility, and performance, but requires configuration updates. - -### Breaking Changes - -#### 1. MCP Mode now defaults to `remote_preferred` - -The `[mcp]` section in `config.toml` still accepts a `mode` setting, but the default behaviour has changed. If you previously relied on `mode = "legacy"`, you can keep that line – the value now maps to the `local_only` runtime with a compatibility warning instead of breaking outright. New installs default to the safer `remote_preferred` mode, which attempts to use any configured external MCP server and automatically falls back to the local in-process tooling when permitted. - -**Supported values (v1.0+):** - -| Value | Behaviour | -|--------------------|-----------| -| `remote_preferred` | Default. Use the first configured `[[mcp_servers]]`, fall back to local if `allow_fallback = true`. -| `remote_only` | Require a configured server; the CLI will error if it cannot start. -| `local_only` | Force the built-in MCP client and the direct Ollama provider. -| `legacy` | Alias for `local_only` kept for compatibility (emits a warning). -| `disabled` | Not supported by the TUI; intended for headless tooling. - -You can additionally control the automatic fallback behaviour: - -```toml -[mcp] -mode = "remote_preferred" -allow_fallback = true -warn_on_legacy = true -``` - -#### 2. Direct Provider Access Removed (with opt-in compatibility) - -In v0.x, Owlen could make direct HTTP calls to Ollama when in "legacy" mode. The default v1.0 behaviour keeps all LLM interactions behind MCP, but choosing `mode = "local_only"` or `mode = "legacy"` now reinstates the direct Ollama provider while still keeping the MCP tooling stack available locally. - -### What Changed Under the Hood - -The v1.0 architecture implements the full 10-phase migration plan: - -- **Phase 1-2**: File operations via MCP servers -- **Phase 3**: LLM inference via MCP servers (Ollama wrapped) -- **Phase 4**: Agent loop with ReAct pattern -- **Phase 5**: Mode system (chat/code) with tool availability -- **Phase 6**: Web search integration -- **Phase 7**: Code execution with Docker sandboxing -- **Phase 8**: Prompt server for versioned prompts -- **Phase 9**: Remote MCP server support (HTTP/WebSocket) -- **Phase 10**: Legacy mode removal and production polish - -### Migration Steps - -#### Step 1: Review Your MCP Configuration - -Edit `~/.config/owlen/config.toml` and ensure the `[mcp]` section reflects how you want to run Owlen: - -```toml -[mcp] -mode = "remote_preferred" -allow_fallback = true -``` - -If you encounter issues with remote servers, you can temporarily switch to: - -```toml -[mcp] -mode = "local_only" # or "legacy" for backwards compatibility -``` - -You will see a warning on startup when `legacy` is used so you remember to migrate later. - -**Quick fix:** run `owlen config doctor` to apply these defaults automatically and validate your configuration file. - -#### Step 2: Verify Provider Configuration - -Ensure your provider configuration is correct. For Ollama: - -```toml -[general] -default_provider = "ollama_local" -default_model = "llama3.2:latest" # or your preferred model - -[providers.ollama_local] -enabled = true -provider_type = "ollama" -base_url = "http://localhost:11434" - -[providers.ollama_cloud] -enabled = true # set to false if you do not use the hosted API -provider_type = "ollama_cloud" -base_url = "https://ollama.com" -api_key_env = "OLLAMA_API_KEY" -``` - -#### Step 3: Understanding MCP Server Configuration - -While not required for basic usage (Owlen will use the built-in local MCP client), you can optionally configure external MCP servers: - -```toml -[[mcp_servers]] -name = "llm" -command = "owlen-mcp-llm-server" -transport = "stdio" - -[[mcp_servers]] -name = "filesystem" -command = "/path/to/filesystem-server" -transport = "stdio" -``` - -**Note**: If no `mcp_servers` are configured, Owlen automatically falls back to its built-in local MCP client, which provides the same functionality. - -#### Step 4: Verify Installation - -After updating your config: - -1. **Check Ollama is running**: - ```bash - curl http://localhost:11434/api/version - ``` - -2. **List available models**: - ```bash - ollama list - ``` - -3. **Test Owlen**: - ```bash - owlen - ``` - -### Common Issues After Migration - -#### Issue: "Warning: No MCP servers defined in config. Using local client." - -**This is normal!** In v1.0+, if you don't configure external MCP servers, Owlen uses its built-in local MCP client. This provides the same functionality without needing separate server processes. - -**No action required** unless you specifically want to use external MCP servers. - -#### Issue: Timeouts on First Message - -**Cause**: Ollama loads models into memory on first use, which can take 10-60 seconds for large models. - -**Solution**: -- Be patient on first inference after model selection -- Use smaller models for faster loading (e.g., `llama3.2:latest` instead of `qwen3-coder:latest`) -- Pre-load models with: `ollama run ` - -#### Issue: Cloud Models Return 404 Errors - -**Cause**: Ollama Cloud model names may differ from local model names. - -**Solution**: -- Verify model availability on https://ollama.com/models -- Remove the `-cloud` suffix from model names when using cloud provider -- Ensure `api_key`/`api_key_env` is set in `[providers.ollama_cloud]` config - -### 0.1.9 – Explicit Ollama Modes & Cloud Endpoint Storage - -Owlen 0.1.9 introduces targeted quality-of-life fixes for users who switch between local Ollama models and Ollama Cloud: - -- `providers..extra.ollama_mode` now accepts `"auto"`, `"local"`, or `"cloud"`. Migrations default existing entries to `auto`, while preserving any explicit local base URLs you set previously. -- `owlen cloud setup` writes the hosted endpoint to `providers..extra.cloud_endpoint` rather than overwriting `base_url`, so local catalogues keep working after you import an API key. Pass `--force-cloud-base-url` if you truly want the provider to point at the hosted service. -- The model picker surfaces `Local unavailable` / `Cloud unavailable` badges when a source probe fails, highlighting what to fix instead of presenting an empty list. - -Run `owlen config doctor` after upgrading to ensure these migration tweaks are applied automatically. - -### Rollback to v0.x - -If you encounter issues and need to rollback: - -1. **Reinstall v0.x**: - ```bash - # Using AUR (if applicable) - yay -S owlen-git - - # Or from source - git checkout - cargo install --path crates/owlen-tui - ``` - -2. **Restore configuration**: - ```toml - [mcp] - mode = "legacy" - ``` - -3. **Report issues**: https://github.com/Owlibou/owlen/issues - -### Benefits of v1.0 MCP Architecture - -- **Modularity**: LLM, file operations, and tools are isolated in MCP servers -- **Extensibility**: Easy to add new tools and capabilities via MCP protocol -- **Multi-Provider**: Support for multiple LLM providers through standard interface -- **Remote Execution**: Can connect to remote MCP servers over HTTP/WebSocket -- **Better Error Handling**: Structured error responses from MCP servers -- **Agentic Capabilities**: ReAct pattern for autonomous task completion - -### Getting Help - -- **Documentation**: See `docs/` directory for detailed guides -- **Issues**: https://github.com/Owlibou/owlen/issues -- **Configuration Reference**: `docs/configuration.md` -- **Troubleshooting**: `docs/troubleshooting.md` - ---- - -## Future Migrations - -We will continue to document breaking changes here as Owlen evolves. Always check this guide when upgrading to a new major version. diff --git a/docs/migrations/README.md b/docs/migrations/README.md deleted file mode 100644 index b1a89f0..0000000 --- a/docs/migrations/README.md +++ /dev/null @@ -1,13 +0,0 @@ -## Migration Notes - -Owlen is still in alpha, so configuration and storage formats may change between releases. This directory collects short guides that explain how to update a local environment when breaking changes land. - -### Schema 1.2.0 (November 2025) - -`config.toml` now records `schema_version = "1.2.0"` and introduces the optional `ui.input_max_rows` and `ui.scrollback_lines` settings. The new keys default to `5` and `2000` respectively, so no manual edits are required unless you want a taller input panel or a different scrollback cap. Existing files are updated automatically on load/save. - -### Schema 1.1.0 (October 2025) - -Owlen `config.toml` files now carry a `schema_version`. On startup the loader upgrades any existing file and warns when deprecated keys are present. No manual changes are required, but if you track the file in version control you may notice `schema_version = "1.1.0"` added near the top. - -If you previously set `agent.max_tool_calls`, replace it with `agent.max_iterations`. The former is now ignored. diff --git a/docs/phase5-mode-system.md b/docs/phase5-mode-system.md deleted file mode 100644 index 36a5b62..0000000 --- a/docs/phase5-mode-system.md +++ /dev/null @@ -1,214 +0,0 @@ -# Phase 5: Mode Consolidation & Tool Availability System - -## Implementation Status: ✅ COMPLETE - -Phase 5 has been fully implemented according to the specification in `.agents/new_phases.md`. - -## What Was Implemented - -### 1. Mode System (✅ Complete) - -**File**: `crates/owlen-core/src/mode.rs` - -- `Mode` enum with `Chat` and `Code` variants -- `ModeConfig` for configuring tool availability per mode -- `ModeToolConfig` with wildcard (`*`) support for allowing all tools -- Default configuration: - - Chat mode: only `web_search` allowed (legacy `web.search` alias still resolves) - - Code mode: all tools allowed (`*`) - -### 2. Configuration Integration (✅ Complete) - -**File**: `crates/owlen-core/src/config.rs` - -- Added `modes: ModeConfig` field to `Config` struct -- Mode configuration loaded from TOML with sensible defaults -- Example configuration: - -```toml -[modes.chat] -allowed_tools = ["web_search"] # Legacy `web.search` alias still accepted - -[modes.code] -allowed_tools = ["*"] # All tools allowed -``` - -### 3. Tool Registry Filtering (✅ Complete) - -**Files**: -- `crates/owlen-core/src/tools/registry.rs` -- `crates/owlen-core/src/mcp.rs` - -Changes: -- `ToolRegistry::execute()` now takes a `Mode` parameter -- Mode-based filtering before tool execution -- Helpful error messages suggesting mode switch if tool unavailable -- `ToolRegistry::available_tools(mode)` method for listing tools per mode -- `McpServer` tracks current mode and filters tool lists accordingly -- `LocalMcpClient` exposes `set_mode()` and `get_mode()` methods - -### 4. CLI Argument (✅ Complete) - -**File**: `crates/owlen-cli/src/main.rs` - -- Added `--code` / `-c` CLI argument using clap -- Sets initial operating mode on startup -- Example: `owlen --code` starts in code mode - -### 5. TUI Commands (✅ Complete) - -**File**: `crates/owlen-tui/src/chat_app.rs` - -New commands added: -- `:mode ` - Switch operating mode explicitly -- `:code` - Shortcut to switch to code mode -- `:chat` - Shortcut to switch to chat mode -- `:tools` - List available tools in current mode - -Implementation details: -- Commands update `operating_mode` field in `ChatApp` -- Status message confirms mode switch -- Error messages for invalid mode names - -### 6. Status Line Indicator (✅ Complete) - -**File**: `crates/owlen-tui/src/ui.rs` - -- Operating mode badge displayed in status line -- `💬 CHAT` badge (blue background) in chat mode -- `💻 CODE` badge (magenta background) in code mode -- Positioned after agent status indicators - -### 7. Documentation (✅ Complete) - -**File**: `crates/owlen-tui/src/ui.rs` (help system) - -Help documentation already included: -- `:code` command with CLI usage hint -- `:mode ` command -- `:tools` command - -## Architecture - -``` -User Input → CLI Args → ChatApp.operating_mode - ↓ - TUI Commands (:mode, :code, :chat) - ↓ - ChatApp.set_mode(mode) - ↓ - Status Line Updates - ↓ - Tool Execution → ToolRegistry.execute(name, args, mode) - ↓ - Mode Check → Config.modes.is_tool_allowed(mode, tool) - ↓ - Execute or Error -``` - -## Testing Checklist - -- [x] Mode enum defaults to Chat -- [x] Config loads mode settings from TOML -- [x] `:mode` command shows current mode -- [x] `:mode chat` switches to chat mode -- [x] `:mode code` switches to code mode -- [x] `:code` shortcut works -- [x] `:chat` shortcut works -- [x] `:tools` lists available tools -- [x] `owlen --code` starts in code mode -- [x] Status line shows current mode -- [ ] Tool execution respects mode filtering (requires runtime test) -- [ ] Mode-restricted tool gives helpful error message (requires runtime test) - -## Configuration Example - -Create or edit `~/.config/owlen/config.toml`: - -```toml -[general] -default_provider = "ollama_local" -default_model = "llama3.2:latest" - -[modes.chat] -# In chat mode, only web search is allowed -# When migrating older configs you can still list `web.search`, but Owlen rewrites it internally. -allowed_tools = ["web_search"] - -[modes.code] -# In code mode, all tools are allowed -allowed_tools = ["*"] - -# You can also specify explicit tool lists: -# allowed_tools = ["web_search", "code_exec", "file_write", "file_delete"] -``` - -## Usage - -### Starting in Code Mode - -```bash -owlen --code -# or -owlen -c -``` - -### Switching Modes at Runtime - -``` -:mode code # Switch to code mode -:code # Shortcut for :mode code -:chat # Shortcut for :mode chat -:mode chat # Switch to chat mode -:mode # Show current mode -:tools # List available tools in current mode -``` - -### Tool Filtering Behavior - -**In Chat Mode:** -- ✅ `web_search` - Allowed (legacy `web.search` alias also resolves) -- ❌ `code_exec` - Blocked (suggests switching to code mode) -- ❌ `file_write` - Blocked -- ❌ `file_delete` - Blocked - -**In Code Mode:** -- ✅ All tools allowed (wildcard `*` configuration) - -## Next Steps - -To fully complete Phase 5 integration: - -1. **Runtime Testing**: Build and run the application to verify: - - Tool filtering works correctly - - Error messages are helpful - - Mode switching updates MCP client when implemented - -2. **MCP Integration**: When MCP is fully implemented, update `ChatApp::set_mode()` to propagate mode changes to the MCP client. - -3. **Additional Tools**: As new tools are added, update the `:tools` command to discover tools dynamically from the registry instead of hardcoding the list. - -## Files Modified - -- `crates/owlen-core/src/mode.rs` (NEW) -- `crates/owlen-core/src/lib.rs` -- `crates/owlen-core/src/config.rs` -- `crates/owlen-core/src/tools/registry.rs` -- `crates/owlen-core/src/mcp.rs` -- `crates/owlen-cli/src/main.rs` -- `crates/owlen-tui/src/chat_app.rs` -- `crates/owlen-tui/src/ui.rs` -- `Cargo.toml` (removed invalid bin sections) - -## Spec Compliance - -All requirements from `.agents/new_phases.md` Phase 5 have been implemented: - -- ✅ 5.1. Remove Legacy Code - MCP is primary integration -- ✅ 5.2. Implement Mode Switching in TUI - Commands and CLI args added -- ✅ 5.3. Define Tool Availability System - Mode enum and ModeConfig created -- ✅ 5.4. Configuration in TOML - modes section added to config -- ✅ 5.5. Integrate Mode Filtering with Agent Loop - ToolRegistry updated -- ✅ 5.6. Config Loader in Rust - Uses existing TOML infrastructure -- ✅ 5.7. TUI Command Extensions - All commands implemented -- ✅ 5.8. Testing & Validation - Unit tests added, runtime tests pending diff --git a/docs/platform-support.md b/docs/platform-support.md deleted file mode 100644 index 1a925f2..0000000 --- a/docs/platform-support.md +++ /dev/null @@ -1,24 +0,0 @@ -# Platform Support - -Owlen targets all major desktop platforms; the table below summarises the current level of coverage and how to verify builds locally. - -| Platform | Status | Notes | -|----------|--------|-------| -| Linux | ✅ Primary | CI and local development happen on Linux. `owlen config doctor` and provider health checks are exercised every run. | -| macOS | ✅ Supported | Tested via local builds. Uses the macOS application support directory for configuration and session data. | -| Windows | ⚠️ Preview | Uses platform-specific paths and compiles via `scripts/check-windows.sh`. Runtime testing is limited—feedback welcome. | - -### Verifying Windows compatibility from Linux/macOS - -```bash -./scripts/check-windows.sh -``` - -The script installs the `x86_64-pc-windows-gnu` target if necessary and runs `cargo check` against it. Run it before submitting PRs that may impact cross-platform support. - -### Troubleshooting - -- Provider startup failures now surface clear hints (e.g. "Ensure Ollama is running"). -- The TUI warns when the active terminal lacks 256-colour capability; consider switching to a true-colour terminal for the best experience. - -Refer to `docs/troubleshooting.md` for additional guidance. diff --git a/docs/provider-implementation.md b/docs/provider-implementation.md deleted file mode 100644 index e92d049..0000000 --- a/docs/provider-implementation.md +++ /dev/null @@ -1,98 +0,0 @@ -# Provider Implementation Guide - -This guide explains how to implement a new provider for Owlen. Providers are the components that connect to different LLM APIs. - -## The `ModelProvider` Trait - -The core of the provider system is the `ModelProvider` trait, located in `owlen-core::provider`. Any new provider must implement this async trait so it can be managed by `ProviderManager`. - -Here is a simplified version of the trait: - -```rust -use async_trait::async_trait; -use owlen_core::provider::{GenerateChunk, GenerateRequest, GenerateStream, ModelInfo, ProviderMetadata, ProviderStatus}; - -#[async_trait] -pub trait ModelProvider: Send + Sync { - fn metadata(&self) -> &ProviderMetadata; - async fn health_check(&self) -> owlen_core::Result; - async fn list_models(&self) -> owlen_core::Result>; - async fn generate_stream(&self, request: GenerateRequest) -> owlen_core::Result; -} -``` - -## Creating a New Crate - -1. **Create a new crate** in the `crates/` directory. For example, `owlen-myprovider`. -2. **Add dependencies** to your new crate's `Cargo.toml`. You will need `owlen-core`, `async-trait`, `tokio`, and any crates required for interacting with the new API (e.g., `reqwest`). -3. **Add the new crate to the workspace** in the root `Cargo.toml`. - -## Implementing the Trait - -In your new crate's `lib.rs`, you will define a struct for your provider and implement the `Provider` trait for it. - -```rust -use async_trait::async_trait; -use owlen_core::provider::{ - GenerateRequest, GenerateStream, ModelInfo, ModelProvider, ProviderMetadata, - ProviderStatus, ProviderType, -}; - -pub struct MyProvider { - metadata: ProviderMetadata, - client: MyHttpClient, -} - -impl MyProvider { - pub fn new(config: &MyConfig) -> owlen_core::Result { - let metadata = ProviderMetadata::new( - "my_provider", - "My Provider", - ProviderType::Cloud, - true, - ); - - Ok(Self { - metadata, - client: MyHttpClient::new(config)?, - }) - } -} - -#[async_trait] -impl ModelProvider for MyProvider { - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - - async fn health_check(&self) -> owlen_core::Result { - self.client.ping().await.map(|_| ProviderStatus::Available) - } - - async fn list_models(&self) -> owlen_core::Result> { - self.client.list_models().await - } - - async fn generate_stream(&self, request: GenerateRequest) -> owlen_core::Result { - self.client.generate(request).await - } -} -``` - -## Integrating with Owlen - -Once your provider is implemented, you will need to register it with the `ProviderManager` and surface it to users. - -1. **Add your provider crate** as a dependency to the component that will host it (an MCP server or `owlen-cli`). -2. **Register the provider** with `ProviderManager` during startup: - -```rust -let manager = ProviderManager::new(config); -manager.register_provider(Arc::new(MyProvider::new(config)?)).await; -``` - -3. **Update configuration docs/examples** so the provider has a `[providers.my_provider]` entry. -4. **Expose via MCP (optional)** if the provider should run out-of-process. Owlen’s TUI talks to providers exclusively via MCP after Phase 10. -5. **Add tests** similar to `crates/owlen-providers/tests/integration_test.rs` that exercise registration, model aggregation, generation routing, and health transitions. - -For concrete examples, see the Ollama providers in `crates/owlen-providers/` and the integration tests added in commit 13. diff --git a/docs/repo-map.md b/docs/repo-map.md deleted file mode 100644 index aa45653..0000000 --- a/docs/repo-map.md +++ /dev/null @@ -1,70 +0,0 @@ -# Repo Map - -> Generated by `scripts/gen-repo-map.sh`. Regenerate whenever the workspace layout changes. - -```text -. -├── crates -│ ├── mcp -│ │ ├── client -│ │ ├── code-server -│ │ ├── llm-server -│ │ ├── prompt-server -│ │ └── server -│ ├── owlen-cli -│ │ ├── src -│ │ ├── tests -│ │ ├── Cargo.toml -│ │ └── README.md -│ ├── owlen-core -│ │ ├── examples -│ │ ├── migrations -│ │ ├── src -│ │ ├── tests -│ │ ├── Cargo.toml -│ │ └── README.md -│ ├── owlen-markdown -│ │ ├── src -│ │ └── Cargo.toml -│ ├── owlen-providers -│ │ ├── src -│ │ ├── tests -│ │ └── Cargo.toml -│ ├── owlen-tui -│ │ ├── src -│ │ ├── tests -│ │ ├── Cargo.toml -│ │ └── README.md -│ └── providers -│ └── experimental -├── docs -│ ├── migrations -│ ├── CHANGELOG_v1.0.md -│ ├── adding-providers.md -│ ├── architecture.md -│ ├── configuration.md -│ ├── faq.md -│ ├── migration-guide.md -│ ├── phase5-mode-system.md -│ ├── platform-support.md -│ ├── provider-implementation.md -│ ├── testing.md -│ └── troubleshooting.md -├── examples -├── scripts -│ ├── check-windows.sh -│ └── gen-repo-map.sh -├── AGENTS.md -├── CHANGELOG.md -├── CODE_OF_CONDUCT.md -├── CONTRIBUTING.md -├── Cargo.lock -├── Cargo.toml -├── LICENSE -├── PKGBUILD -├── README.md -├── SECURITY.md -└── config.toml - -29 directories, 32 files -``` diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index 9ef4989..0000000 --- a/docs/testing.md +++ /dev/null @@ -1,58 +0,0 @@ -# Testing Guide - -This guide provides instructions on how to run existing tests and how to write new tests for Owlen. - -## Running Tests - -The entire test suite can be run from the root of the repository using the standard `cargo test` command. - -```sh -# Run all tests in the workspace -cargo test --all - -# Run tests for a specific crate -cargo test -p owlen-core -``` - -We use `cargo clippy` for linting and `cargo fmt` for formatting. Please run these before submitting a pull request. - -```sh -cargo clippy --all -- -D warnings -cargo fmt --all -- --check -``` - -## Writing New Tests - -Tests are located in the `tests/` directory within each crate, or in a `tests` module at the bottom of the file they are testing. We follow standard Rust testing practices. - -### Unit Tests - -For testing specific functions or components in isolation, use unit tests. These should be placed in a `#[cfg(test)]` module in the same file as the code being tested. - -```rust -// in src/my_module.rs - -pub fn add(a: i32, b: i32) -> i32 { - a + b -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_add() { - assert_eq!(add(2, 2), 4); - } -} -``` - -### Integration Tests - -For testing how different parts of the application work together, use integration tests. These should be placed in the `tests/` directory of the crate. - -For example, to test the `SessionController`, you might create a mock `Provider` and simulate sending messages, as seen in the `SessionController` documentation example. - -### TUI and UI Component Tests - -Testing TUI components can be challenging. For UI logic in `owlen-core` (like `wrap_cursor`), we have detailed unit tests that manipulate the component's state and assert the results. For higher-level TUI components in `owlen-tui`, the focus is on testing the state management logic rather than the visual output. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index 01f0a21..0000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,143 +0,0 @@ -# Troubleshooting Guide - -This guide is intended to help you with common issues you might encounter while using Owlen. - -## Connection Failures to Ollama - -If you are unable to connect to a local Ollama instance, here are a few things to check: - -1. **Is Ollama running?** Make sure the Ollama service is active. You can usually check this with `ollama list`. -2. **Is the address correct?** By default, Owlen tries to connect to `http://localhost:11434`. If your Ollama instance is running on a different address or port, you will need to configure it in your `config.toml` file. -3. **Firewall issues:** Ensure that your firewall is not blocking the connection. -4. **Health check warnings:** Owlen now performs a provider health check on startup. If it fails, the error message will include a hint (either "start owlen-mcp-llm-server" or "ensure Ollama is running"). Resolve the hint and restart. - -## Model Not Found Errors - -Owlen surfaces this as `InvalidInput: Model '' was not found`. - -1. **Local models:** Run `ollama list` to confirm the model name (e.g., `llama3:8b`). Use `ollama pull ` if it is missing. -2. **Ollama Cloud:** Names may differ from local installs. Double-check https://ollama.com/models and remove `-cloud` suffixes. -3. **Fallback:** Switch to `mode = "local_only"` temporarily in `[mcp]` if the remote server is slow to update. - -Fix the name in your configuration file or choose a model from the UI (`:model`). - -## Local Models Missing After Cloud Setup - -Owlen now queries both the local daemon and Ollama Cloud and shows them side-by-side in the picker. If you only see the cloud section (or a red `Local unavailable` banner): - -1. **Confirm the daemon is reachable.** Run `ollama list` locally. If the command times out, restart the service (`ollama serve` or your systemd unit). -2. **Refresh the picker.** In the TUI press `:models --local` to focus the local section. The footer will explain if Owlen skipped the source because it was unreachable. -3. **Inspect the status line.** When the quick health probe fails, Owlen adds a `Local unavailable` / `Cloud unavailable` message instead of leaving the list blank. Use that hint to decide whether to restart Ollama or re-run `owlen cloud setup`. -4. **Keep the base URL local.** The cloud setup command no longer overrides `providers.ollama.base_url` unless `--force-cloud-base-url` is passed. If you changed it manually, edit `config.toml` or run `owlen config doctor` to restore the default `http://localhost:11434` value. - -Once the daemon responds again, the picker will automatically merge the updated local list with the cloud catalogue. -Owlen runs a background health worker every 30 seconds; once the daemon responds it will update the picker automatically without needing a restart. - -## Terminal Compatibility Issues - -Owlen is built with `ratatui`, which supports most modern terminals. However, if you are experiencing rendering issues, please check the following: - -- Your terminal supports Unicode. -- You are using a font that includes the characters being displayed. -- Try a different terminal emulator to see if the issue persists. - -## Configuration File Problems - -If Owlen is not behaving as you expect, there might be an issue with your configuration file. - -- **Location:** Run `owlen config path` to print the exact location (Linux, macOS, or Windows). Owlen now follows platform defaults instead of hard-coding `~/.config`. -- **Init:** Run `owlen config init` to recreate the latest default file (`--force` will overwrite an existing configuration). -- **Syntax:** The configuration file is in TOML format. Make sure the syntax is correct. -- **Values:** Check that the values for your models, providers, and other settings are correct. -- **Automation:** Run `owlen config doctor` to migrate legacy settings (`mode = "legacy"`, missing providers) and validate the file before launching the TUI. - -## Ollama Cloud Authentication Errors - -If you see `Auth` errors when using the hosted service: - -1. Run `owlen cloud setup --api-key ` to register your API key (omit the flag only if you have already stored credentials in the vault or config file). -2. Use `owlen cloud status` to verify Owlen can authenticate against [Ollama Cloud](https://docs.ollama.com/cloud) with the canonical `https://ollama.com` base URL. Override the endpoint via `providers.ollama_cloud.base_url` only if your account is pointed at a custom region. -3. Ensure `providers.ollama_cloud.api_key` is set **or** export `OLLAMA_API_KEY` (legacy: `OLLAMA_CLOUD_API_KEY` / `OWLEN_OLLAMA_CLOUD_API_KEY`) when encryption is disabled. With `privacy.encrypt_local_data = true`, the key lives in the encrypted vault and is loaded automatically. -4. Confirm the key has access to the requested models. Recent accounts scope access per workspace; visit while signed in to double-check the SKU name. -5. Owlen disables the cloud provider after consecutive 401/403 responses, posts a toast, and falls back to the last healthy local provider so you can keep chatting. Re-run `owlen cloud setup` and flip back with `:provider ollama_cloud` once the key is valid again. -6. Avoid pasting extra quotes or whitespace into the config file—`owlen config doctor` will normalise the entry for you. - -## Ollama Cloud Rate Limits (HTTP 429) - -If the hosted API returns `HTTP 429 Too Many Requests`, Owlen keeps the provider enabled but surfaces a rate-limit toast and replays your message against the local provider so you do not lose work. To recover: - -1. Check the cockpit header or run `:limits` to see your locally tracked hourly/weekly totals. When either bar crosses 80% Owlen warns you; 95% triggers a critical toast. -2. Raise or remove the soft quotas (`providers.ollama_cloud.hourly_quota_tokens`, `weekly_quota_tokens`) if your vendor allotment is higher, or pause cloud usage until the next window resets. -3. If you need the cloud-only model, retry after the provider’s cooling-off period (Ollama currently resets the rate window hourly for most SKUs). Adjust `list_ttl_secs` upward if automated refreshes are consuming too many tokens. -4. Use `:web off` (or `owlen providers web --disable`) to keep the session local until the rate window resets; re-enable with `:web on` / `owlen providers web --enable` when you want cloud lookups again. - -### Linux Ollama Sign-In Workaround (v0.12.3) - -Ollama v0.12.3 on Linux ships with a broken `ollama signin` command. Until you can upgrade to ≥0.12.4, use one of the manual workflows below to register your key pair. - -#### 1. Manual key copy - -1. **Locate (or generate) keys on Linux** - ```bash - ls -la /usr/share/ollama/.ollama/ - sudo systemctl start ollama # start the service if the directory is empty - ``` -2. **Copy keys from a working Mac** - ```bash - # On macOS (source machine) - cat ~/.ollama/id_ed25519.pub - cat ~/.ollama/id_ed25519 - ``` - ```bash - # On Linux (target machine) - sudo systemctl stop ollama - sudo mkdir -p /usr/share/ollama/.ollama - sudo tee /usr/share/ollama/.ollama/id_ed25519.pub <<'EOF' - - EOF - sudo tee /usr/share/ollama/.ollama/id_ed25519 <<'EOF' - - EOF - sudo chown -R ollama:ollama /usr/share/ollama/.ollama/ - sudo chmod 600 /usr/share/ollama/.ollama/id_ed25519 - sudo chmod 644 /usr/share/ollama/.ollama/id_ed25519.pub - sudo systemctl start ollama - ``` - -#### 2. Manual web registration - -1. Read the Linux public key: - ```bash - sudo cat /usr/share/ollama/.ollama/id_ed25519.pub - ``` -2. Open and paste the public key. - -After either method, confirm access: - -```bash -ollama list -``` - -#### Troubleshooting - -- Permissions: `sudo chown -R ollama:ollama /usr/share/ollama/.ollama/` then re-apply `chmod` (`600` private, `644` public). -- Service status: `sudo systemctl status ollama` and `sudo journalctl -u ollama -f`. -- Alternate paths: Some distros run Ollama as a user process (`~/.ollama`). Copy the keys into that directory if `/usr/share/ollama/.ollama` is unused. - -This workaround mirrors what `ollama signin` should do—register the key pair with Ollama Cloud—without waiting for the patched release. Once you upgrade to v0.12.4 or newer, the interactive sign-in command works again. - -## Repository Automation Issues - -- **401 / authentication errors**: `owlen repo review` needs a GitHub token. Export `GITHUB_TOKEN=` (with at least `repo:read`) before running the command or use `--token `. In the TUI, the same environment variable is read when `:repo review` is executed. -- **404 responses**: Verify that the `--owner` and `--repo` flags match the full name of the repository (`owner/repo`). A 404 from GitHub for private projects usually means the token lacks access. -- **Enterprise endpoints**: Supply `--api-endpoint https://github.example.com/api/v3` so the client uses your self-hosted instance instead of `api.github.com`. - -## Performance Tuning - -If you are experiencing performance issues, you can try the following: - -- **Reduce context size:** A smaller context size will result in faster responses from the LLM. -- **Use a less resource-intensive model:** Some models are faster but less capable than others. -- **Watch the header gauges:** The cockpit header now shows live context usage and cloud quota bands—if either bar turns amber or red, trim the prompt or switch providers before retrying. - -If you are still having trouble, please [open an issue](https://github.com/Owlibou/owlen/issues) on our GitHub repository. diff --git a/docs/tui-mvu-migration.md b/docs/tui-mvu-migration.md deleted file mode 100644 index 1d0790f..0000000 --- a/docs/tui-mvu-migration.md +++ /dev/null @@ -1,109 +0,0 @@ -# TUI MVU Migration Guide - -This guide explains how we are migrating the Owlen terminal UI to a predictable **Model–View–Update (MVU)** architecture. Use it to understand the current layout, decide where new logic belongs, and track which features have already moved to the MVU core. - ---- - -## Goals - -- Make UI state transitions pure and testable. -- Reduce duplicated control flow inside `chat_app.rs`. -- Keep rendering functions dumb; they should depend on read-only view models. -- Ensure new features land in MVU-first form so the imperative paths shrink over time. - -Adopt the checklist below whenever you touch a feature that still lives in the imperative code path. - ---- - -## Module Map (owlen-tui) - -| Area | Path | Responsibility | MVU Status | -| --- | --- | --- | --- | -| Core state | `src/app/mvu.rs` | Shared `AppModel`, `AppEvent`, `AppEffect` definitions | **Ready** – composer + consent events implemented | -| Legacy app | `src/chat_app.rs` | Orchestrates IO, manages pending tasks, renders via ratatui | **Transitioning** – increasingly delegates to MVU | -| Event loop | `src/app/handler.rs` | Converts session messages into app updates | Needs cleanup once message flow is MVU aware | -| Rendering | `src/ui.rs` + `src/widgets/*` | Pure rendering helpers that pull data from `ChatApp` | Already read-only; keep that invariant | -| Commands | `src/commands/*` | Keymap and palette command registry | Candidate for MVU once palette state migrates | -| Shared state | `src/state/*` | Small state helpers (command palette, file tree, etc.) | Each module can become an MVU sub-model | - -Use the table to find the right starting point before adding new events. - ---- - -## Event Taxonomy - -Current events live in `app/mvu.rs`. - -- `AppEvent::Composer` – covers draft changes, mode switches, submissions. -- `AppEvent::ToolPermission` – bridges consent dialog choices back to the controller. - -`AppEffect` represents side effects the imperative shell must execute: - -- `SetStatus` – surface validation failures. -- `RequestSubmit` – hand control back to the async send pipeline. -- `ResolveToolConsent` – notify the session controller of user decisions. - -### Adding a new feature - -1. Extend `AppModel` with the new view state. -2. Create a dedicated event enum (e.g. `PaletteEvent`) and nest it under `AppEvent`. -3. Add pure update logic that mutates the model and returns zero or more effects. -4. Handle emitted effects inside `ChatApp::handle_app_effects`. - -Keep the event names UI-centric. Provider-side actions should remain in `owlen-core`. - ---- - -## Feature Migration Checklist - -| Feature | Scope | MVU tasks | Status | -| --- | --- | --- | --- | -| Composer (input buffer) | Draft text, submission workflow | ✅ `ComposerModel`, `ComposerEvent`, `SubmissionOutcome` | ✅ Complete | -| Tool consent dialog | Approval / denial flow | ✅ `AppEvent::ToolPermission`, `AppEffect::ResolveToolConsent` | ✅ Complete | -| Chat timeline | Message ordering, cursor, scrollback | Model struct for timeline + events for history updates | ☐ TODO | -| Thinking pane | Agent reasoning text, auto-scroll | Model + event to toggle visibility and append lines | ☐ TODO | -| Model picker | Filters, search, selection | Convert `ModelSelectorItem` list + search metadata into MVU | ☐ TODO | -| Command palette | Suggestions, history, apply actions | Move palette state into `AppModel` and surface events | ☐ TODO | -| File workspace | Pane layout, file tree focus | Represent pane tree in MVU, drive focus + resize events | ☐ TODO | -| Toasts & status bar | Transient notifications | Consider MVU-managed queue with explicit events | ☐ TODO | - -When you pick up one of the TODO rows, document the plan in the PR description and link back to this table. - ---- - -## Migration Playbook - -1. **Inventory state** – list every field in `ChatApp` that your feature touches. -2. **Define view model** – move the persistent state into `AppModel` (or a new sub-struct). -3. **Write events** – describe all user intents and background updates as `AppEvent` variants. -4. **Translate side effects** – whenever the update logic needs to call into async code, emit an `AppEffect`. Handle it inside `handle_app_effects`. -5. **Refactor call sites** – replace direct mutations with `apply_app_event` calls. -6. **Write tests** – cover the pure update function with table-driven unit tests. -7. **Remove duplicates** – once the MVU path handles everything, delete the legacy branch in `chat_app.rs`. - -This flow keeps commits reviewable and avoids breaking the live UI during migration. - ---- - -## Testing Guidance - -- **Unit tests** – cover the pure update functions inside `app/mvu.rs`. -- **Integration tests** – add scenarios to `crates/owlen-tui/tests/agent_flow_ui.rs` when side effects change. -- **Golden behaviour** – ensure the ratatui renderers still consume read-only data; add lightweight snapshot tests if needed. -- **Manual verification** – run `cargo run -p owlen-cli -- --help` to open the TUI and confirm the migrated feature behaves as expected. - -Every new MVU feature should land with unit tests plus a note about manual validation. - ---- - -## Tracking TODOs - -- Keep this file up to date when you migrate a feature. -- Add inline `// TODO(mvu)` tags in code with a short description so they are easy to grep. -- Use the `docs/` folder for design notes; avoid long comment blocks inside the code. - -Future contributors should be able to glance at this document, see what is done, and understand where to continue the migration. - ---- - -Questions? Reach out in the Owlen discussion board or drop a note in the relevant PR thread. Consistent updates here will keep MVU adoption predictable for everyone. diff --git a/docs/tui-ux-playbook.md b/docs/tui-ux-playbook.md deleted file mode 100644 index 3251ad1..0000000 --- a/docs/tui-ux-playbook.md +++ /dev/null @@ -1,206 +0,0 @@ -# Owlen TUI UX & Keybinding Playbook - -*Last updated: October 25, 2025* - -This playbook documents the design principles, modal ergonomics, command -metadata, theming tokens, and animation policy that guide Owlen’s terminal UI. -Use it both as a contributor reference and as a migration guide when extending -custom keymaps or UI affordances. - ---- - -## 1. Modal Philosophy - -Owlen embraces a Vim-inspired modal workflow with discoverability helpers -layered on top. - -| Mode | Entry | Primary Purpose | Escape | -|-----------------|------------------------------------------|----------------------------------------------------------------------|--------| -| **Normal** | `Esc`, `Ctrl+[`, application start | Navigation, pane switching, command prefixes, leader chords | `i`, `:`, `?`, `F1` | -| **Editing** | `i`, `a`, `Enter` in Normal | Compose prompt, multi-line editing, history cycling (`Ctrl+↑/↓`) | `Esc` | -| **Visual** | `v` in Normal | Region selection across chat, thinking, and input panes | `Esc`, `v` | -| **Command** | `:` | Run palette commands (`:session save`, `:mode code`, `:limits`) | `Esc`, `Enter` | -| **Help** | `F1`, `?` | Cheat sheet tabs, onboarding tour | `Esc`, `F1` | -| **Browsers** | `:sessions`, `:themes`, repo search keys | Context-specific pickers with shared navigation primitives | `Esc` | - -Modal transitions fire `AppEvent::Composer::ModeChanged`, enabling keymap -state to reset cleanly (see `ChatApp::set_input_mode`). Maintain this flow -when adding new modes to avoid orphaned key sequences. - -### Focus Shortcuts - -- `Ctrl+1…5` focus Files, Chat, Code, Thinking, Input (mirrors leader chords). -- `Tab` / `Shift+Tab` cycle panes; `g t` toggles Files visibility in Vim keymap. -- `Ctrl+Alt+5` (Vim) / `Alt+5` (Emacs) jump back to the input editor. - -Keep focus hops single-chord and mnemonic. Prefer documenting any new shortcut -in the cheat sheet (`render_guidance_cheatsheet`) and the status HUD hints. - ---- - -## 2. Keybinding Design & Migration - -### Built-in Profiles - -- **Vim** (default): stored in [`crates/owlen-tui/keymap.toml`](../crates/owlen-tui/keymap.toml). -- **Emacs**: stored in [`crates/owlen-tui/keymap_emacs.toml`](../crates/owlen-tui/keymap_emacs.toml). - -Switch at runtime via `:keymap vim|emacs`; persist with -`ui.keymap_profile = "vim"` or `"emacs"` in `config.toml`. - -### Custom Keymaps - -Set `ui.keymap_path = "/absolute/path/to/keymap.toml"` to override the built-in -profiles. The schema matches the shipped TOML files: - -```toml -[[bindings]] -mode = "normal" -sequence = ["g", "t"] -command = "workspace.toggle_files" -``` - -**Migration notes (v0.2+)** - -1. **Multi-step sequences**: All keymaps now support arbitrary-length - sequences (`Ctrl+W`, `w`, `Ctrl+K`, `←`). Update custom maps to the trie - syntax if you previously shadowed hard-coded Rust handlers. -2. **Leader remapping**: `ui.keymap_leader` defaults to space. Custom profiles - should mirror the new leader sections so the cheat sheet can surface - alternate hints (`binding_pair_string`). -3. **Discoverability hooks**: Register new commands in - `commands::catalog()` with category, summary, tags, and optional - `preview` callback. The command palette relies on this metadata for fuzzy - search and tag filtering. -4. **Help overlay**: If you introduce new focus or layout commands, add them to - `build_cheatsheet_sections` so onboarding and help stay in sync. - -Run `cargo test -p owlen-tui --test chat_snapshots` after keymap changes to -refresh guidance snapshots. - ---- - -## 3. Command Metadata Schema - -Located in [`crates/owlen-tui/src/commands/catalog.rs`](../crates/owlen-tui/src/commands/catalog.rs), -each command descriptor should populate: - -- `category`: High-level grouping shown in the palette (`navigation`, `session`, `provider`). -- `keywords`: Fuzzy aliases; include verbs (open, focus), nouns (chat, files), and tags (`#agent`). -- `key_hint`: Optional string rendered in previews (e.g., `Ctrl+Shift+F`). -- `modes`: Restrict availability by `InputMode` when needed (e.g., editing-only commands). -- `preview`: Use for sidecar previews (`commands::Preview`), returning lines that explain impact. - -When adding new commands: - -1. Define the handler in `commands::handlers`. -2. Register metadata in `catalog()` and supply tests in - `crates/owlen-tui/tests/state_tests.rs` or palette-specific snapshots. -3. Update the cheat sheet if the command introduces a new focus or mode affordance. - ---- - -## 4. Theming & Layer Tokens - -Themes live in `owlen_core::theme`. The TUI applies a glass/neon layer via -`GlassPalette::for_theme_with_mode` using `config.ui.layers`: - -| Setting | Default | Description | -|--------------------------|---------|----------------------------------------------------------| -| `shadow_elevation` | `2` | Depth of drop shadow around the chat stage. | -| `glass_tint` | `0.82` | Opacity mix between base theme background and frosted layer. | -| `neon_intensity` | `60` | Percentage controlling accent saturation. | -| `focus_ring` | `true` | Draws neon outlines around the active stage container. | - -**Best practices** - -- Keep contrasts AA+; avoid low-alpha custom tints when `accessibility_high_contrast = true`. -- Re-render custom palettes through the snapshot suite to catch regression diffs. - ---- - -## 5. Animation Policy - -Controlled by `config.ui.animations`: - -| Field | Effect | Range | -|--------------------|--------------------------------------------------|------------| -| `micro` | Enables pane glow, gauge easing, mode flash. | `true/false` | -| `gauge_smoothing` | Exponential smoothing factor for usage gauges. | `0.05–1.0` | -| `pane_decay` | Decay applied to pulse effects on pane switches. | `0.2–0.95` | - -Accessibility flags (`ui.accessibility.high_contrast`, `ui.accessibility.reduced_chrome`) automatically disable micro animations. When adding new animations: - -1. Guard with `ChatApp::micro_animations_enabled()`. -2. Provide non-animated fallbacks (e.g., direct gauge `snap`). -3. Update documentation and consider adding snapshot coverage under both - animated and non-animated configurations. - ---- - -## 6. Status Surface & Toasts - -The status HUD (`render_status`) now renders three columns: - -1. **Left**: Mode/operating badges, focus hints, agent state, help shortcuts. -2. **Center**: Primary status message with auto-leveled badge (info/success/warning/error) and contextual usage gauges. -3. **Right**: Repository/path summary, provider & model badge, streaming hints, and toast history. - -Toast enhancements: - -- Icons per severity (`ⓘ`, `✔`, `⚠`, `✖`). -- Optional keyboard hints via `push_toast_with_hint`. -- Progress bar countdown plus remaining seconds. -- Persistent history (20 entries) exposed to the HUD for quick recall. - -**Guidelines** - -- Provide actionable hints (e.g., `"F12 · Debug log"`, `":limits"`). -- Reserve `ToastLevel::Error` for recoverable issues the user must address. -- Use status primary line for the most recent action; employ `system_status` - for secondary context. - ---- - -## 7. Contributor Checklist - -When modifying TUI UX or keybindings: - -1. Update this playbook if the change affects modal ergonomics, theming tokens, - animation policy, or keymap conventions. -2. Extend the cheat sheet / onboarding overlays for new focus or command - shortcuts. -3. Refresh snapshots: `INSTA_UPDATE=always cargo test -p owlen-tui --test chat_snapshots`. -4. Run `cargo clippy -p owlen-tui -- -D warnings` and the `owlen-tui` test suite. -5. Document user-facing changes in `CHANGELOG.md`. - -Keeping these steps in sync ensures Owlen’s keyboard-first UX remains -predictable, accessible, and discoverable. - -## 8. Screenshot Pipeline - -Use the scripted pipeline to regenerate gallery assets and documentation -illustrations: - -- `cargo xtask screenshots` emits ANSI dumps under `images/generated` and, by - default, converts them to PNG with `chafa`. Pass `--no-png` to skip PNG - rendering or `--chafa /path/to/chafa` to override the binary. Each scene is - deterministic—no network calls—and mirrors the regression snapshots. -- The pipeline reuses the same stub provider harness as the snapshot tests, so - new scenes should be added in tandem with `chat_snapshots.rs` to keep visual - regression coverage and documentation imagery aligned. - -## 9. Transcript Compression - -- The compactor lives under `[chat]` in `config.toml`. Defaults keep - `auto_compress = true`, `trigger_tokens = 6000`, and retain the last eight - turns verbatim. -- Strategy is configurable: `provider` summaries call back through the active - model (or `chat.model_override`), while `local` uses a heuristic bullet list - for fully offline runs. -- Users can disable the feature per session with `owlen --no-auto-compress`, or - at runtime via `:compress auto on|off`. `:compress now` triggers an immediate - compaction even when auto mode is disabled. -- Each compression pass replaces older turns with a system summary annotated by - `message.metadata.compression` (strategy, timestamps, token deltas, and the - archived message ids) to support audits and future rehydration tools. diff --git a/examples/custom_theme.rs b/examples/custom_theme.rs deleted file mode 100644 index 592201f..0000000 --- a/examples/custom_theme.rs +++ /dev/null @@ -1,28 +0,0 @@ -// This example demonstrates how to create a custom theme programmatically. - -use owlen_core::theme::Theme; -use ratatui::style::{Color, Style}; - -fn create_custom_theme() -> Theme { - Theme { - name: "My Custom Theme".to_string(), - author: "Your Name".to_string(), - comment: "A simple custom theme".to_string(), - base: Style::default().fg(Color::White).bg(Color::Black), - user_chat: Style::default().fg(Color::Green), - bot_chat: Style::default().fg(Color::Cyan), - error: Style::default().fg(Color::Red), - info: Style::default().fg(Color::Yellow), - border: Style::default().fg(Color::Gray), - input: Style::default().fg(Color::White), - ..Default::default() - } -} - -fn main() { - let custom_theme = create_custom_theme(); - - println!("Created custom theme: {}", custom_theme.name); - println!("Author: {}", custom_theme.author); - println!("User chat color: {:?}", custom_theme.user_chat.fg); -} diff --git a/examples/mcp_chat.rs b/examples/mcp_chat.rs deleted file mode 100644 index e20d333..0000000 --- a/examples/mcp_chat.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Example demonstrating MCP-based chat interaction. -//! -//! This example shows the recommended way to interact with LLMs via the MCP architecture. -//! It uses `RemoteMcpClient` which communicates with the MCP LLM server. -//! -//! Prerequisites: -//! - Build the MCP LLM server: `cargo build --release -p owlen-mcp-llm-server` -//! - Ensure Ollama is running with a model available - -use owlen_core::{ - Provider, - mcp::remote_client::RemoteMcpClient, - types::{ChatParameters, ChatRequest, Message, Role}, -}; -use std::sync::Arc; - -#[tokio::main] -async fn main() -> Result<(), anyhow::Error> { - println!("🦉 Owlen MCP Chat Example\n"); - - // Create MCP client - this will spawn/connect to the MCP LLM server - println!("Connecting to MCP LLM server..."); - let client = Arc::new(RemoteMcpClient::new().await?); - println!("✓ Connected\n"); - - // List available models - println!("Fetching available models..."); - let models = client.list_models().await?; - println!("Available models:"); - for model in &models { - println!(" - {} ({})", model.name, model.provider); - } - println!(); - - // Select first available model or default - let model_name = models - .first() - .map(|m| m.id.clone()) - .unwrap_or_else(|| "llama3.2:latest".to_string()); - println!("Using model: {}\n", model_name); - - // Create a simple chat request - let user_message = "What is the capital of France? Please be concise."; - println!("User: {}", user_message); - - let request = ChatRequest { - model: model_name, - messages: vec![Message::new(Role::User, user_message.to_string())], - parameters: ChatParameters { - temperature: Some(0.7), - max_tokens: Some(100), - stream: false, - extra: std::collections::HashMap::new(), - }, - tools: None, - }; - - // Send request and get response - println!("\nAssistant: "); - let response = client.send_prompt(request).await?; - println!("{}", response.message.content); - - if let Some(usage) = response.usage { - println!( - "\n📊 Tokens: {} prompt + {} completion = {} total", - usage.prompt_tokens, usage.completion_tokens, usage.total_tokens - ); - } - - Ok(()) -} diff --git a/examples/session_management.rs b/examples/session_management.rs deleted file mode 100644 index 20f47a0..0000000 --- a/examples/session_management.rs +++ /dev/null @@ -1,30 +0,0 @@ -// This example demonstrates how to use the session controller. - -use owlen_core::session::Session; - -fn main() { - // Create a new session. - let mut session = Session::new("my-session"); - println!("Created new session: {}", session.name); - - // Add messages to the session. - session.add_message("user", "Hello, Owlen!"); - session.add_message("bot", "Hello, user! How can I help you today?"); - - // Get the messages from the session. - let messages = session.get_messages(); - println!("\nMessages in session:"); - for message in messages { - println!(" {}: {}", message.role, message.content); - } - - // Clear the session. - session.clear_messages(); - println!("\nSession cleared."); - - let messages_after_clear = session.get_messages(); - println!( - "Messages in session after clear: {}", - messages_after_clear.len() - ); -} diff --git a/images/chat_view.png b/images/chat_view.png deleted file mode 100644 index a4070574a34dce83a94a5f9aa1d3822ca5a09388..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51204 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 ZFtRc=gXodF{^uRY`JS$RF6*2UngH4m;vWD2 diff --git a/images/layout.png b/images/layout.png deleted file mode 100644 index 4f3166ad6f610906b8cb2ce5acb03f0f578535b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65891 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

-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 diff --git a/images/model_select.png b/images/model_select.png deleted file mode 100644 index 04bed8600c18f2e23f72e5adb205413d3b07c9be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69158 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

$Os*MtyVAccrNBd9*)oov!fZocg)n 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, -) -> Result { - // ... existing code ... - "websocket" => { - let ws_url = config.command.clone(); - let (ws_stream, _response) = connect_async(&ws_url) - .await - .map_err(|e| Error::Network(format!("WebSocket connection failed: {}", e)))?; - // ... rest of initialization ... - } -} -``` - -- **Estimated Effort**: 4-6 hours (straightforward async refactor + call site updates) - -**Why This Matters**: `block_in_place` is designed for CPU-bound work, not I/O. Using it for network I/O defeats tokio's cooperative scheduling and can cause cascading delays in the event loop. Users report "frozen terminal" symptoms when connecting to slow MCP servers. - ---- - -## High-Priority Issues (P1) - -### Issue: Excessive Clone Operations in ProviderManager - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/provider/manager.rs:94-100, 162-168` -- **Severity**: High (Performance) -- **Impact**: Every model listing call clones all provider Arc handles and IDs, causing unnecessary allocations in hot path -- **Root Cause**: `list_all_models` and `refresh_health` acquire read lock, collect into Vec with clones, then release lock - -**Evidence**: -```rust -// Lines 94-100 -let providers: Vec<(String, Arc)> = { - let guard = self.providers.read().await; - guard - .iter() - .map(|(id, provider)| (id.clone(), Arc::clone(provider))) - .collect() -}; -``` - -- **Recommended Fix**: - 1. Keep lock held during parallel health check spawning (health checks are async, so lock isn't held during actual work) - 2. Or: Use `Arc::clone()` only (remove `id.clone()` by using `&str` in async block) - 3. Consider `DashMap` instead of `RwLock` for lock-free reads - -```rust -// Option 1: Minimize scope -let guard = self.providers.read().await; -for (provider_id, provider) in guard.iter() { - let provider_id = provider_id.clone(); // Only 1 clone - let provider = Arc::clone(provider); // Arc bump is cheap - tasks.push(async move { /* ... */ }); -} -drop(guard); // Explicitly release -``` - -- **Estimated Effort**: 2-3 hours - -**Impact**: Profiling shows 15-20% of `list_all_models` time spent on String clones in configurations with 5+ providers. - ---- - -### Issue: Potential Panic in Path Traversal Check - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/mcp/remote_client.rs:409-411` -- **Severity**: High (Security) -- **Impact**: Path traversal check is incomplete; attackers can bypass with URL-encoded `..` or absolute Windows paths -- **Root Cause**: Naive string-based check instead of canonical path validation - -**Evidence**: -```rust -// Lines 408-411 -if path.contains("..") || Path::new(path).is_absolute() { - return Err(Error::InvalidInput("path traversal".into())); -} -``` - -**Attack Vectors**: -- URL-encoded: `resources_write?path=%2E%2E%2Fetc%2Fpasswd` (bypasses `.contains("..")`) -- Windows UNC: `\\?\C:\Windows\System32\config` (not caught by `is_absolute()` on Unix) -- Symlink exploitation: Write to `/tmp/foo` which is a symlink to `/etc/passwd` - -- **Recommended Fix**: -```rust -use std::path::{Path, PathBuf}; -use path_clean::PathClean; - -fn validate_safe_path(path: &str) -> Result { - let path = urlencoding::decode(path).map_err(|_| Error::InvalidInput("invalid path encoding"))?; - let path = Path::new(path.as_ref()); - - // Reject absolute paths early - if path.is_absolute() { - return Err(Error::InvalidInput("absolute paths not allowed")); - } - - // Canonicalize relative to current working directory - let canonical = std::env::current_dir() - .map_err(Error::Io)? - .join(path) - .clean(); // Remove `..` components - - // Ensure result is still within workspace - let workspace = std::env::current_dir().map_err(Error::Io)?; - if !canonical.starts_with(&workspace) { - return Err(Error::InvalidInput("path escapes workspace")); - } - - Ok(canonical) -} -``` - -- **Estimated Effort**: 4-6 hours (includes test cases for all attack vectors) - ---- - -### Issue: Missing Error Handling in Session Blocking Lock - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/session.rs:1204-1212` -- **Severity**: High (Reliability) -- **Impact**: Lock poisoning or panic in background thread causes silent failure or deadlock -- **Root Cause**: `blocking_lock()` can panic if mutex is poisoned; no error propagation - -**Evidence**: -```rust -// Lines 1207, 1212 -tokio::task::block_in_place(|| self.config.blocking_lock()) -``` - -If `blocking_lock()` panics (mutex poisoned), the entire task panics without cleanup. - -- **Recommended Fix**: -```rust -pub fn get_something(&self) -> Result { - let guard = tokio::task::block_in_place(|| { - self.config.try_lock() - .map_err(|_| Error::Storage("Lock poisoned".into())) - })?; - // use guard -} -``` - -- **Estimated Effort**: 2 hours - ---- - -### Issue: Unbounded Channel in App Message Loop - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-tui/src/app/mod.rs:73` -- **Severity**: High (Resource Exhaustion) -- **Impact**: Fast provider responses + slow UI rendering = unbounded memory growth -- **Root Cause**: `mpsc::unbounded_channel` used for app messages without backpressure - -**Evidence**: -```rust -// Line 73 -let (message_tx, message_rx) = mpsc::unbounded_channel(); -``` - -**Scenario**: User sends 100 rapid requests to fast provider (Ollama local). Each response generates 20-50 AppMessage chunks. UI rendering lags (complex markdown parsing), causing queue depth to exceed 5000 messages → OOM on systems with <4GB RAM. - -- **Recommended Fix**: -```rust -// Use bounded channel with graceful degradation -let (message_tx, message_rx) = mpsc::channel(256); - -// In sender: -match message_tx.try_send(msg) { - Ok(()) => {}, - Err(mpsc::error::TrySendError::Full(_)) => { - // Drop message and emit warning - log::warn!("App message queue full, dropping message"); - } - Err(mpsc::error::TrySendError::Closed(_)) => { - return Err(Error::Unknown("App channel closed".into())); - } -} -``` - -- **Estimated Effort**: 6-8 hours (requires testing under load) - ---- - -### Issue: Rust 2024 Edition but Collapsible If Still Suppressed - -- **Location**: Multiple files (lib.rs, main.rs in 3 crates) -- **Severity**: High (Code Quality) -- **Impact**: Let-chains are stable in Rust 2024 edition, but clippy warnings still suppressed -- **Root Cause**: Codebase was migrated to `edition = "2024"` but legacy suppression attributes remain - -**Evidence**: -```rust -// crates/owlen-core/src/lib.rs:1 -#![allow(clippy::collapsible_if)] // TODO: Remove once we can rely on Rust 2024 let-chains -``` - -```toml -// Cargo.toml:20 -edition = "2024" -``` - -Rust 1.82+ with edition 2024 supports let-chains natively, making this suppression unnecessary. - -- **Recommended Fix**: - 1. Remove `#![allow(clippy::collapsible_if)]` from all 3 files - 2. Run `cargo clippy --all -- -D warnings` - 3. Refactor any flagged collapsible ifs to use let-chains: - -```rust -// Before -if let Some(val) = opt { - if val > 10 { - // ... - } -} - -// After (2024 edition) -if let Some(val) = opt && val > 10 { - // ... -} -``` - -- **Estimated Effort**: 2-3 hours - ---- - -### Issue: No Timeout on MCP RPC Calls - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/mcp/remote_client.rs:204-338` -- **Severity**: High (Reliability) -- **Impact**: Hung MCP servers cause indefinite blocking; TUI becomes unresponsive -- **Root Cause**: `send_rpc` has no timeout mechanism; reads from stdout in infinite loop - -**Evidence**: -```rust -// Line 306 -stdout.read_line(&mut line).await?; // Can block forever -``` - -**Scenario**: MCP server enters deadlock or infinite loop. `read_line` waits indefinitely. User cannot cancel, must kill process. - -- **Recommended Fix**: -```rust -use tokio::time::{timeout, Duration}; - -async fn send_rpc(&self, method: &str, params: Value) -> Result { - // ... build request ... - - let result = timeout(Duration::from_secs(30), async { - // ... send and read logic ... - loop { - let mut line = String::new(); - let mut stdout = self.stdout.as_ref() - .ok_or_else(|| Error::Network("STDIO stdout not available"))? - .lock().await; - stdout.read_line(&mut line).await?; - // ... parse response ... - } - }).await - .map_err(|_| Error::Timeout("MCP request timed out after 30s".into()))??; - - Ok(result) -} -``` - -- **Estimated Effort**: 3-4 hours - ---- - -### Issue: Version 0.3.0 of ollama-rs May Have Breaking Changes - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/Cargo.toml:46` -- **Severity**: High (Dependency) -- **Impact**: ollama-rs is at 0.x version, no stability guarantees; breaking changes likely -- **Root Cause**: Direct dependency on unstable crate version - -**Evidence**: -```toml -ollama-rs = { version = "0.3", features = ["stream", "headers"] } -``` - -**Research Needed**: Check ollama-rs changelog for 0.3.x → 0.4.0 migration path. Consider vendoring or wrapping in abstraction layer. - -- **Recommended Fix**: - 1. Pin exact version: `ollama-rs = "=0.3.5"` (check latest 0.3.x) - 2. Create `providers::ollama::OllamaClient` wrapper trait isolating ollama-rs usage - 3. Add integration tests covering all ollama-rs API calls used - 4. Monitor https://github.com/pepperoni21/ollama-rs for breaking changes - -- **Estimated Effort**: 4 hours (wrapper abstraction) - ---- - -### Issue: Potential SQL Injection in Session Metadata Queries - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/storage.rs:127-150` -- **Severity**: High (Security - Theoretical) -- **Impact**: If session names/descriptions are unsanitized, could enable SQL injection -- **Root Cause**: Using sqlx query! macros correctly with bind params, but worth auditing - -**Evidence**: -```rust -// Lines 127-150 - SAFE (uses bind params) -sqlx::query(r#" - INSERT INTO conversations (id, name, description, ...) - VALUES (?1, ?2, ?3, ...) -"#) -.bind(serialized.id.to_string()) -.bind(name.or(serialized.name.clone())) -``` - -**Audit Result**: Current implementation is **safe** (uses parameterized queries), but: -- Session names are user-controlled and stored in DB -- No validation on name length (could cause DoS with 10MB name) -- Description field generated by LLM could contain malicious content if misused elsewhere - -- **Recommended Fix**: - 1. Add validation: max 256 chars for name, 1024 for description - 2. Add unit test attempting SQL injection via name field - 3. Document that these fields must never be used in raw SQL construction - -```rust -pub async fn save_conversation(/* ... */) -> Result<()> { - // Validate name length - if let Some(ref n) = name { - if n.len() > 256 { - return Err(Error::InvalidInput("Session name exceeds 256 characters".into())); - } - } - // ... rest of function -} -``` - -- **Estimated Effort**: 2 hours - ---- - -### Issue: No Rate Limiting on Provider Health Checks - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/provider/manager.rs:161-197` -- **Severity**: Medium (Resource Usage) -- **Impact**: Aggressive health check polling (every 5s) amplifies provider load 12x -- **Root Cause**: No caching or rate limiting on `refresh_health()` - -**Evidence**: `refresh_health()` spawns parallel health checks for all providers on every call. If TUI polls every 5 seconds and 5 providers exist → 60 health checks/minute per provider. - -- **Recommended Fix**: -```rust -use std::time::{Duration, Instant}; - -pub struct ProviderManager { - // ... existing fields ... - last_health_check: RwLock>, - health_cache_ttl: Duration, -} - -impl ProviderManager { - pub async fn refresh_health(&self) -> HashMap { - // Check cache freshness - let last_check = self.last_health_check.read().await; - if let Some(instant) = *last_check { - if instant.elapsed() < self.health_cache_ttl { - return self.status_cache.read().await.clone(); // Return cached - } - } - drop(last_check); - - // Perform actual check - // ... existing logic ... - - // Update timestamp - *self.last_health_check.write().await = Some(Instant::now()); - updates - } -} -``` - -- **Estimated Effort**: 3 hours - ---- - -## Medium-Priority Issues (P2) - -### Issue: Unused `dead_code` Allowances on Production Structs - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/mcp/remote_client.rs:30, 40` -- **Severity**: Medium (Code Quality) -- **Impact**: Indicates incomplete usage of struct fields or overly broad suppressions -- **Root Cause**: `#[allow(dead_code)]` on `child` and `ws_endpoint` fields - -**Evidence**: -```rust -// Line 30 -#[allow(dead_code)] -child: Option>>, - -// Line 40 -#[allow(dead_code)] -ws_endpoint: Option, -``` - -**Analysis**: -- `child` field should actually be used - it keeps subprocess alive during lifetime -- `ws_endpoint` is genuinely unused (only for debugging as comment says) - -- **Recommended Fix**: - 1. Remove `#[allow(dead_code)]` from `child` - it's necessary for RAII - 2. If `ws_endpoint` is truly for debugging, rename to `_ws_endpoint` (Rust idiom) or remove entirely - 3. Run `cargo clippy` to find any other hidden issues - -- **Estimated Effort**: 30 minutes - ---- - -### Issue: Magic Numbers in Chat Application Constants - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-tui/src/chat_app.rs:121-135` -- **Severity**: Medium (Maintainability) -- **Impact**: Unclear why specific values chosen, hard to tune performance -- **Root Cause**: Constants defined without documentation or rationale - -**Evidence**: -```rust -const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); -const RESIZE_STEP: f32 = 0.05; -const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; -const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); -const MIN_MESSAGE_CARD_WIDTH: usize = 14; -const MOUSE_SCROLL_STEP: isize = 3; -const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 8_192; -const MAX_QUEUE_ATTEMPTS: u8 = 3; -const THOUGHT_SUMMARY_LIMIT: usize = 5; -``` - -- **Recommended Fix**: Add doc comments explaining each constant's purpose and chosen value - -```rust -/// Maximum time between two resize keypresses to trigger snap-to-preset behavior. -/// Set to 450ms based on typical user double-tap speed (200-600ms range). -const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); - -/// Amount to adjust split ratio per resize keypress. 0.05 = 5% increments. -const RESIZE_STEP: f32 = 0.05; - -/// Common split ratios to snap to when double-tapping resize keys. -/// [50%, 75% left, 25% left] -const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; - -/// Maximum time between two Ctrl+C presses to trigger force exit. -/// 1.5s chosen to avoid accidental exits while allowing quick escape. -const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); -``` - -- **Estimated Effort**: 1 hour - ---- - -### Issue: HashMap Cloning in Provider Status Cache - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/provider/manager.rs:220` -- **Severity**: Medium (Performance) -- **Impact**: `provider_statuses()` clones entire HashMap every call -- **Root Cause**: Returning owned HashMap instead of reference or snapshot - -**Evidence**: -```rust -// Line 220 -pub async fn provider_statuses(&self) -> HashMap { - let guard = self.status_cache.read().await; - guard.clone() -} -``` - -- **Recommended Fix**: -```rust -// Option 1: Return Arc to immutable snapshot -use std::sync::Arc; - -pub async fn provider_statuses(&self) -> Arc> { - let guard = self.status_cache.read().await; - Arc::new(guard.clone()) // Clone once, share via Arc -} - -// Option 2: Use evmap for lock-free copy-on-write -// Replace RwLock with evmap::ReadHandle -``` - -- **Estimated Effort**: 2 hours - ---- - -### Issue: Inconsistent Error Handling in MCP Client - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/mcp/remote_client.rs:364-400` -- **Severity**: Medium (UX) -- **Impact**: Local file operations use `.map_err(Error::Io)` but tool execution errors disappear -- **Root Cause**: `resources_get`, `resources_write` handle local I/O inline with basic error propagation - -**Evidence**: -```rust -// Line 372 -let content = std::fs::read_to_string(path).map_err(Error::Io)?; -``` - -Good error propagation, but: -```rust -// Line 388-391 -for entry in std::fs::read_dir(path).map_err(Error::Io)?.flatten() { - if let Some(name) = entry.file_name().to_str() { - names.push(name.to_string()); - } -} -``` - -Silent failure: If `to_str()` returns `None` (invalid UTF-8), entry is silently skipped. - -- **Recommended Fix**: -```rust -for entry in std::fs::read_dir(path).map_err(Error::Io)? { - let entry = entry.map_err(Error::Io)?; - let name = entry.file_name() - .to_str() - .ok_or_else(|| Error::InvalidInput("Non-UTF-8 filename".into()))? - .to_string(); - names.push(name); -} -``` - -- **Estimated Effort**: 1 hour - ---- - -### Issue: Potential Integer Overflow in Token Estimation - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/session.rs:59-91` -- **Severity**: Medium (Correctness) -- **Impact**: Large messages or attachments could overflow u32 token count -- **Root Cause**: Using `saturating_add` but not validating input ranges - -**Evidence**: -```rust -// Lines 72-73 -let approx = max(4, content.chars().count() / 4 + 1); -approx + 4 -} as u32; -``` - -If `content.chars().count()` exceeds `(u32::MAX - 4) * 4`, the `as u32` cast will silently wrap. - -- **Recommended Fix**: -```rust -fn estimate_message_tokens(message: &Message) -> u32 { - let content = message.content.trim(); - let base = if content.is_empty() { - 4 - } else { - let char_count = content.chars().count(); - // Clamp to prevent overflow before division - let approx = (char_count.min(u32::MAX as usize - 4) / 4 + 1).min(u32::MAX as usize - 4); - (approx + 4) as u32 - }; - - message.attachments.iter().fold(base, |acc, attachment| { - // Use saturating_add consistently - let bonus = /* ... */; - acc.saturating_add(bonus) - }) -} -``` - -- **Estimated Effort**: 2 hours - ---- - -### Issue: Missing Tests for Provider Manager Edge Cases - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/provider/manager.rs:283-471` -- **Severity**: Medium (Test Coverage) -- **Impact**: No tests for health check failures, concurrent registration, or status cache invalidation -- **Root Cause**: Only 3 tests cover happy paths - -**Evidence**: Existing tests only cover: -1. `aggregates_local_provider_models` - basic model listing -2. `aggregates_cloud_provider_models` - cloud provider variant -3. `deduplicates_model_names_with_provider_suffix` - name collision - -Missing tests: -- Provider health check transitions (Available → Unavailable → Available) -- Concurrent `register_provider` + `list_all_models` -- Generate request failure propagation -- Empty provider registry -- Provider registration after initial construction - -- **Recommended Fix**: Add integration tests: - -```rust -#[tokio::test] -async fn handles_provider_health_degradation() { - // Test Available → Unavailable transition updates cache -} - -#[tokio::test] -async fn concurrent_registration_is_safe() { - // Spawn multiple tasks calling register_provider -} - -#[tokio::test] -async fn generate_failure_updates_status() { - // Verify failed generate() marks provider Unavailable -} -``` - -- **Estimated Effort**: 4-6 hours - ---- - -### Issue: Confusing Function Naming - `enrich_model_metadata` - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/provider/manager.rs:224` -- **Severity**: Medium (Readability) -- **Impact**: Function mutates slice in-place but "enrich" sounds like it returns new data -- **Root Cause**: Naming convention doesn't match Rust idioms (should be `_mut` suffix or return new Vec) - -**Evidence**: -```rust -fn enrich_model_metadata(models: &mut [AnnotatedModelInfo]) { - // ... mutates models in place ... -} -``` - -- **Recommended Fix**: -```rust -// Option 1: Add _mut suffix -fn enrich_model_metadata_mut(models: &mut [AnnotatedModelInfo]) { /* ... */ } - -// Option 2: Return new Vec -fn enrich_model_metadata(models: Vec) -> Vec { - let mut models = models; - // ... mutation ... - models -} -``` - -- **Estimated Effort**: 15 minutes - ---- - -### Issue: No Cleanup on RemoteMcpClient Drop - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/mcp/remote_client.rs:28-46` -- **Severity**: Medium (Resource Leak) -- **Impact**: Child processes may outlive Rust process, become zombies or orphans -- **Root Cause**: No `Drop` implementation to kill child process and close streams - -**Evidence**: -```rust -pub struct RemoteMcpClient { - child: Option>>, - stdin: Option>>, - // ... no Drop impl ... -} -``` - -When `RemoteMcpClient` is dropped: -1. `child` Arc is dropped, but Child destructor doesn't kill process -2. STDIO MCP servers keep running as orphans -3. On Linux: reaped by init, but on Windows: may accumulate - -- **Recommended Fix**: -```rust -impl Drop for RemoteMcpClient { - fn drop(&mut self) { - if let Some(child_arc) = self.child.take() { - // Try to kill child process - if let Ok(mut child) = child_arc.try_lock() { - let _ = child.kill(); // Best effort, ignore errors - } - } - } -} -``` - -- **Estimated Effort**: 1 hour - ---- - -### Issue: Potential Deadlock in Session Controller - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-tui/src/chat_app.rs:1340` -- **Severity**: Medium (Reliability) -- **Impact**: `block_in_place(|| self.controller.blocking_lock())` holds lock while calling other async methods -- **Root Cause**: Mixing sync and async lock acquisition - -**Evidence**: -```rust -// Line 1340 -task::block_in_place(|| self.controller.blocking_lock()) -``` - -If controller lock is already held by async code, `blocking_lock()` will spin indefinitely. - -- **Recommended Fix**: - 1. Use `tokio::sync::Mutex` throughout (async locks) - 2. Or: Clearly document lock ordering and never mix sync/async locks on same Mutex - -```rust -// Replace std::sync::Mutex with tokio::sync::Mutex -use tokio::sync::Mutex; - -// Then use async lock acquisition -let controller = self.controller.lock().await; -``` - -- **Estimated Effort**: 4 hours (requires auditing all lock sites) - ---- - -### Issue: Markdown Parsing Performance Not Measured - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-markdown/src/lib.rs` -- **Severity**: Medium (Performance - Unknown) -- **Impact**: Complex markdown (tables, code blocks, lists) might block UI rendering -- **Root Cause**: No benchmarks exist for markdown parsing hot path - -**Recommended Fix**: -1. Add criterion benchmarks: - -```rust -// benches/markdown_bench.rs -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use owlen_markdown::from_str; - -fn bench_large_code_block(c: &mut Criterion) { - let markdown = format!("```rust\n{}\n```", "fn main() {}\n".repeat(1000)); - c.bench_function("parse 1000-line code block", |b| { - b.iter(|| from_str(black_box(&markdown))) - }); -} - -criterion_group!(benches, bench_large_code_block); -criterion_main!(benches); -``` - -2. Profile with `cargo flamegraph` on representative workload - -- **Estimated Effort**: 3-4 hours - ---- - -### Issue: No Validation on MCP Tool Name Format - -- **Location**: MCP server implementations -- **Severity**: Medium (Protocol Compliance) -- **Impact**: CLAUDE.md documents spec `^[A-Za-z0-9_-]{1,64}$` but no enforcement -- **Root Cause**: Tool registration doesn't validate names against spec - -**Evidence**: CLAUDE.md line 106-113: -```markdown -### MCP Tool Naming -Enforce spec-compliant identifiers: `^[A-Za-z0-9_-]{1,64}$` -``` - -But in code, no validation exists in tool registration. - -- **Recommended Fix**: -```rust -// In tool registry -use regex::Regex; -use once_cell::sync::Lazy; - -static TOOL_NAME_PATTERN: Lazy = Lazy::new(|| { - Regex::new(r"^[A-Za-z0-9_-]{1,64}$").unwrap() -}); - -pub fn register_tool(name: &str, descriptor: McpToolDescriptor) -> Result<()> { - if !TOOL_NAME_PATTERN.is_match(name) { - return Err(Error::InvalidInput(format!( - "Tool name '{}' violates MCP spec pattern ^[A-Za-z0-9_-]{{1,64}}$", - name - ))); - } - // ... register ... -} -``` - -- **Estimated Effort**: 2 hours - ---- - -## Low-Priority Issues (P3) - -### Issue: Inconsistent Documentation of TUI Keybindings - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-tui/src/chat_app.rs:110-116` -- **Severity**: Low (Documentation) -- **Impact**: Onboarding strings hardcoded, duplicate information in help system -- **Root Cause**: Keybinding hints defined as string constants instead of derived from keymap - -**Recommended Fix**: Generate status line hints from KeymapProfile definition - -- **Estimated Effort**: 2 hours - ---- - -### Issue: Color Serialization Doesn't Handle Indexed Colors - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/theme.rs:1187-1208` -- **Severity**: Low (Feature Gap) -- **Impact**: 256-color palette `Color::Indexed(u8)` serializes as `"#ffffff"` (fallback) -- **Root Cause**: `color_to_string` only handles named colors and RGB - -**Evidence**: -```rust -// Line 1206 -Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b), -_ => "#ffffff".to_string(), // Silently drops Indexed/other variants -``` - -- **Recommended Fix**: -```rust -fn color_to_string(color: &Color) -> String { - match color { - // ... existing cases ... - Color::Indexed(idx) => format!("indexed:{}", idx), - Color::Reset => "reset".to_string(), - _ => { - log::warn!("Unsupported color variant, defaulting to white"); - "#ffffff".to_string() - } - } -} -``` - -- **Estimated Effort**: 1 hour - ---- - -### Issue: Unused Import Warning in Test Module - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/model.rs` -- **Severity**: Low (Code Hygiene) -- **Impact**: Clippy warnings reduce signal-to-noise in CI -- **Root Cause**: Test-only imports not guarded with `#[cfg(test)]` - -**Recommended Fix**: Run `cargo clippy --fix --allow-dirty` and review changes - -- **Estimated Effort**: 30 minutes - ---- - -### Issue: Missing Module-Level Documentation in `owlen-providers` - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-providers/src/lib.rs` -- **Severity**: Low (Documentation) -- **Impact**: `cargo doc` output lacks crate-level overview -- **Root Cause**: No `//!` module doc comment - -**Recommended Fix**: -```rust -//! Provider implementations for OWLEN LLM client. -//! -//! This crate contains concrete implementations of the `ModelProvider` trait -//! defined in `owlen-core`. Each provider adapter translates OWLEN's unified -//! interface to the specific API of a backend service (Ollama, OpenAI, etc.). -//! -//! # Available Providers -//! - `OllamaLocalProvider`: Connects to local Ollama daemon (default: localhost:11434) -//! - `OllamaCloudProvider`: Connects to ollama.com cloud service (requires API key) -//! -//! # Usage -//! ```no_run -//! use owlen_providers::OllamaLocalProvider; -//! let provider = OllamaLocalProvider::new("http://localhost:11434").await?; -//! ``` -``` - -- **Estimated Effort**: 1 hour (across all crates) - ---- - -### Issue: Repetitive Color Constant Definitions in Themes - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/theme.rs:530-1132` -- **Severity**: Low (Maintainability) -- **Impact**: 600+ lines of repetitive color definitions, error-prone to maintain -- **Root Cause**: Each theme is a hand-written function instead of data-driven - -**Recommended Fix**: Themes should be TOML files loaded at runtime (already partially implemented with `built_in_themes()`). Remove hardcoded fallback functions and rely on embedded TOML. - -- **Estimated Effort**: 2-3 hours - ---- - -### Issue: No .gitignore for target/ in Workspace Root - -- **Location**: Repository root -- **Severity**: Low (Repository Hygiene) -- **Impact**: None if .gitignore exists, but worth verifying -- **Root Cause**: Standard Rust .gitignore should exclude `target/`, `Cargo.lock` (for libraries) - -**Recommended Fix**: Verify `.gitignore` contains: -``` -/target/ -**/*.rs.bk -*.pdb -.env -.DS_Store -``` - -- **Estimated Effort**: 5 minutes - ---- - -### Issue: Inconsistent Use of `log::` vs `println!` for Debugging - -- **Location**: Various files -- **Severity**: Low (Observability) -- **Impact**: Debug output goes to different sinks, hard to filter -- **Root Cause**: No clear guidance on when to use structured logging vs stdout - -**Recommended Fix**: Add to CONTRIBUTING.md: -- Use `log::debug!` for development debugging -- Use `log::info!` for user-facing status updates -- Use `log::warn!` for recoverable errors -- Never use `println!` except in CLI argument parsing - -- **Estimated Effort**: 1 hour (audit + document) - ---- - -### Issue: Test Utility Functions Duplicated Across Crates - -- **Location**: Multiple `tests/common/mod.rs` files -- **Severity**: Low (DRY Violation) -- **Impact**: Bug fixes in test utilities need to be propagated manually -- **Root Cause**: No shared test utilities crate - -**Recommended Fix**: Create `owlen-test-utils` crate with shared fixtures: -```rust -// crates/owlen-test-utils/src/lib.rs -pub mod fixtures { - pub fn mock_conversation() -> Conversation { /* ... */ } - pub fn mock_provider() -> MockProvider { /* ... */ } -} -``` - -- **Estimated Effort**: 3 hours - ---- - -### Issue: Default Theme Selection Logic Hardcoded - -- **Location**: `/home/cnachtigall/data/git/projects/Owlibou/owlen/crates/owlen-core/src/theme.rs:285-289` -- **Severity**: Low (Flexibility) -- **Impact**: Cannot easily change default theme without code modification -- **Root Cause**: `Default::default()` returns `default_dark()` instead of loading from config - -**Recommended Fix**: Config should specify default theme name, fall back to "default_dark" only if unset. - -- **Estimated Effort**: 1 hour - ---- - -### Issue: No Contribution Guidelines for Theme Submission - -- **Location**: Repository documentation -- **Severity**: Low (Community) -- **Impact**: Contributors don't know how to submit custom themes -- **Root Cause**: Missing `docs/themes.md` - -**Recommended Fix**: Create theme contribution guide with: -- Template TOML file -- Color palette generator tool -- Screenshot requirements -- Accessibility checklist (contrast ratios) - -- **Estimated Effort**: 2 hours - ---- - -## Optimization Opportunities - -### Performance - -1. **Replace RwLock with DashMap in ProviderManager** (High Impact) - - **Location**: `provider/manager.rs:27-28` - - **Expected Gain**: 30-40% reduction in lock contention for high-frequency status checks - - **Effort**: 4 hours - -2. **Implement Markdown Parsing Cache** (Medium Impact) - - **Location**: `owlen-markdown` crate - - **Strategy**: Cache parsed markdown by content hash (LRU with 100-entry limit) - - **Expected Gain**: 10-15% faster message rendering for repeated content - - **Effort**: 6 hours - -3. **Batch Status Updates in Provider Health Worker** (Medium Impact) - - **Location**: `provider/manager.rs:161` - - **Strategy**: Accumulate status changes and write once instead of per-provider - - **Expected Gain**: Reduce lock acquisitions from N to 1 per health check cycle - - **Effort**: 2 hours - -4. **Use `Arc` Instead of `String` for Model Names** (Low Impact) - - **Location**: `provider/types.rs` - - **Strategy**: Model names are immutable and frequently cloned; Arc reduces allocations - - **Expected Gain**: 5-10% reduction in clone overhead - - **Effort**: 3 hours - -### Memory - -1. **Implement Message History Pruning** (High Impact) - - **Location**: `conversation.rs` - - **Strategy**: Auto-compress or archive messages beyond configured limit (default: 1000) - - **Expected Gain**: Prevent unbounded memory growth in long-running sessions - - **Effort**: 8 hours (requires compression strategy design) - -2. **Use Box for Large Static Strings** (Low Impact) - - **Location**: Theme definitions, error messages - - **Strategy**: Replace `String` with `Box` for never-modified strings - - **Expected Gain**: Marginal (few KB saved) - - **Effort**: 1 hour - -### Async Runtime - -1. **Remove All `block_in_place` Calls** (Critical) - - **Locations**: `session.rs:1207`, `remote_client.rs:142`, `chat_app.rs:1340` - - **Strategy**: Convert to async Mutex or restructure code to avoid blocking - - **Expected Gain**: Eliminate TUI stuttering during I/O operations - - **Effort**: 12 hours - -2. **Use Spawn-Blocking for CPU-Bound Markdown Rendering** (Medium Impact) - - **Location**: `owlen-markdown` parsing calls in TUI - - **Strategy**: Move complex markdown rendering to `spawn_blocking` threadpool - - **Expected Gain**: Prevent event loop blocking for 100+ line code blocks - - **Effort**: 4 hours - -3. **Implement Streaming JSON Parsing for MCP Responses** (Low Impact) - - **Location**: `mcp/remote_client.rs` - - **Strategy**: Use `serde_json::from_reader` instead of reading entire line into String - - **Expected Gain**: Reduce memory spikes for large tool outputs - - **Effort**: 3 hours - ---- - -## Dependency Updates - -| Crate | Current | Latest | Breaking Changes | Recommendation | -|-------|---------|--------|------------------|----------------| -| tokio | 1.0 | 1.42 | None | **Update to 1.42** (perf improvements) | -| ratatui | 0.29 | 0.29.0 | N/A | **Up to date** ✓ | -| crossterm | 0.28.1 | 0.28.1 | N/A | **Up to date** ✓ | -| serde | 1.0 | 1.0.215 | None | **Update to 1.0.215** | -| serde_json | 1.0 | 1.0.133 | None | **Update to 1.0.133** | -| reqwest | 0.12 | 0.12.9 | None | **Update to 0.12.9** (security fixes) | -| sqlx | 0.7 | 0.8.2 | **Major** | **Hold at 0.7** - 0.8 has breaking changes in query! macro | -| thiserror | 2.0 | 2.0.9 | None | **Update to 2.0.9** | -| anyhow | 1.0 | 1.0.93 | None | **Update to 1.0.93** | -| uuid | 1.0 | 1.11.0 | None | **Update to 1.11** (performance improvements) | -| ollama-rs | 0.3 | 0.3.7 | Unknown | **Pin to =0.3.7** and monitor for 0.4.0 | -| tokio-tungstenite | 0.21 | 0.24 | Moderate | **Defer** - test in staging first | -| base64 | 0.22 | 0.22.1 | None | **Update to 0.22.1** | -| image | 0.25 | 0.25.5 | None | **Update to 0.25.5** (security fixes) | - -**Priority Updates** (Run in next sprint): -```bash -cargo update -p tokio --precise 1.42.0 -cargo update -p reqwest --precise 0.12.9 -cargo update -p serde --precise 1.0.215 -cargo update -p serde_json --precise 1.0.133 -cargo update -p uuid --precise 1.11.0 -cargo update -p image --precise 0.25.5 -``` - -**Security Advisory Check**: Run `cargo audit` to identify known vulnerabilities. No CVEs found in current dependencies as of this analysis. - ---- - -## Architecture Recommendations - -### 1. Extract UI Abstractions to Separate Crate (Critical) - -**Problem**: owlen-core violates its own design principle by depending on ratatui/crossterm. - -**Proposed Structure**: -``` -owlen-core/ # Pure business logic (no UI deps) -├── provider/ -├── session/ -├── mcp/ -└── types/ - -owlen-ui-common/ # NEW: Shared UI abstractions -├── theme.rs # Moved from owlen-core -├── color.rs # Abstract color type -└── cursor.rs # UI state types - -owlen-tui/ # Terminal implementation -├── app/ -├── widgets/ -└── impl/theme.rs # Maps Color → ratatui::Color -``` - -**Benefits**: -- Enables headless CLI tools to use owlen-core without TUI deps -- Paves way for future GUI frontends (egui, iced) -- Clarifies dependency graph -- Reduces compile times for server binaries - -**Migration Path**: -1. Create owlen-ui-common crate with abstract Color enum -2. Move theme.rs to ui-common -3. Update owlen-core to depend on ui-common (not ratatui) -4. Update owlen-tui to map Color → ratatui::Color -5. Run full test suite - -**Estimated Effort**: 2-3 days - ---- - -### 2. Introduce Provider Health Check Budget System - -**Problem**: No rate limiting or backoff for provider health checks; aggressive polling amplifies load. - -**Proposed Design**: -```rust -pub struct HealthCheckBudget { - /// Maximum health checks per minute per provider - rate_limit: RateLimiter, - /// Exponential backoff for failed checks - backoff: ExponentialBackoff, -} - -impl ProviderManager { - pub async fn refresh_health_with_budget(&self) -> HashMap { - for (provider_id, provider) in self.providers.read().await.iter() { - if !self.budget.allow(provider_id) { - // Use cached status - continue; - } - - match provider.health_check().await { - Ok(status) => { - self.budget.record_success(provider_id); - // ... - } - Err(_) => { - self.budget.record_failure(provider_id); - // Apply exponential backoff - } - } - } - } -} -``` - -**Benefits**: -- Reduces load on flaky providers -- Prevents thundering herd problem -- More respectful of rate limits - -**Estimated Effort**: 8 hours - ---- - -### 3. Implement Circuit Breaker for Provider Calls - -**Problem**: Repeated failures to unavailable providers delay responses. - -**Proposed Design**: -```rust -use std::sync::atomic::{AtomicU32, Ordering}; - -pub struct ProviderCircuitBreaker { - failure_count: AtomicU32, - threshold: u32, - state: Mutex, -} - -enum CircuitState { - Closed, - Open { until: Instant }, - HalfOpen, -} - -impl ProviderManager { - pub async fn generate(&self, provider_id: &str, request: GenerateRequest) -> Result { - if self.circuit_breaker.is_open(provider_id) { - return Err(Error::Provider("Circuit breaker open".into())); - } - - match provider.generate_stream(request).await { - Ok(stream) => { - self.circuit_breaker.record_success(provider_id); - Ok(stream) - } - Err(e) => { - self.circuit_breaker.record_failure(provider_id); - Err(e) - } - } - } -} -``` - -**Benefits**: -- Fail fast when provider is down -- Automatic recovery via half-open probes -- Reduces wasted timeout waits - -**Estimated Effort**: 12 hours - ---- - -### 4. Introduce Provider Trait Version Negotiation - -**Problem**: Future provider API changes will break all implementations simultaneously. - -**Proposed Design**: -```rust -pub trait ModelProviderV2: Send + Sync { - fn version(&self) -> &'static str { "2.0" } - - // New method: streaming with backpressure control - async fn generate_stream_controlled( - &self, - request: GenerateRequest, - backpressure: BackpressureHandle, - ) -> Result; - - // Deprecate old method - #[deprecated(since = "0.3.0", note = "Use generate_stream_controlled")] - async fn generate_stream(&self, request: GenerateRequest) -> Result { - self.generate_stream_controlled(request, BackpressureHandle::default()).await - } -} -``` - -**Benefits**: -- Gradual migration path for provider updates -- Clear compatibility matrix -- Easier to add features like streaming control - -**Estimated Effort**: 16 hours - ---- - -## Testing Gaps - -### Critical Path Coverage Gaps - -1. **Provider Manager Concurrent Access** (Priority: High) - - **Missing**: Test for race condition when registering provider during model list - - **Scenario**: Thread A calls `list_all_models()`, Thread B calls `register_provider()` mid-iteration - - **Expected Behavior**: Either complete with old list or new list, never partial - - **Suggested Test**: - ```rust - #[tokio::test] - async fn concurrent_registration_during_listing() { - let manager = ProviderManager::default(); - let barrier = Arc::new(tokio::sync::Barrier::new(2)); - - let m1 = Arc::new(manager); - let m2 = Arc::clone(&m1); - let b1 = Arc::clone(&barrier); - let b2 = Arc::clone(&barrier); - - let list_task = tokio::spawn(async move { - b1.wait().await; - m1.list_all_models().await - }); - - let register_task = tokio::spawn(async move { - b2.wait().await; - m2.register_provider(/* new provider */).await - }); - - let (list_result, _) = tokio::join!(list_task, register_task); - assert!(list_result.is_ok()); - } - ``` - -2. **MCP Protocol Error Recovery** (Priority: High) - - **Missing**: Tests for partial response handling, malformed JSON, unexpected message order - - **Scenario**: MCP server sends notification, then response, then error for same request ID - - **Expected Behavior**: Skip notification, parse response, ignore stale error - - **Suggested Test**: Mock STDIO with controlled byte stream - -3. **Session Compression Edge Cases** (Priority: Medium) - - **Missing**: Test for compression with attachments, tool calls, empty messages - - **Scenario**: Compress conversation with 10 messages: 3 text, 2 with images, 5 tool results - - **Expected Behavior**: Preserve tool context, summarize text, keep image refs - - **Suggested Test**: Use actual LLM provider or mock with deterministic responses - -4. **TUI Event Loop Stress Test** (Priority: Medium) - - **Missing**: Test for rapid user input during active generation - - **Scenario**: User types 1000 chars/sec while streaming response arrives - - **Expected Behavior**: No input loss, queue depth <100, latency <50ms - - **Suggested Test**: Synthetic event generator + metrics collection - -5. **Path Traversal Attack Vectors** (Priority: High - Security) - - **Missing**: Tests for URL encoding, symlinks, Windows UNC paths, case sensitivity - - **Test Cases**: - ```rust - #[test] - fn rejects_url_encoded_parent_dir() { - let call = McpToolCall { - name: "resources_write".into(), - arguments: json!({"path": "%2E%2E%2Fetc%2Fpasswd", "content": "pwned"}), - }; - let result = client.call_tool(call).await; - assert!(matches!(result, Err(Error::InvalidInput(_)))); - } - - #[test] - fn rejects_windows_unc_path() { - let call = McpToolCall { - name: "resources_write".into(), - arguments: json!({"path": "\\\\?\\C:\\Windows\\System32\\drivers\\etc\\hosts", "content": "127.0.0.1 evil.com"}), - }; - assert!(matches!(client.call_tool(call).await, Err(_))); - } - ``` - -### Integration Test Gaps - -1. **End-to-End Provider Failover** (Priority: High) - - **Scenario**: Primary provider goes down mid-stream, fallback to secondary - - **Current State**: No tests exist - - **Recommended**: Add test with mock providers that fail after N chunks - -2. **MCP Server Lifecycle** (Priority: Medium) - - **Scenario**: Server crashes, restarts, client reconnects - - **Current State**: Only happy path tested - - **Recommended**: Test with flakey server fixture - -3. **Multi-Provider Model Discovery** (Priority: Medium) - - **Scenario**: 3 providers (local, cloud, custom) each with overlapping model names - - **Current State**: Only 2-provider deduplication tested - - **Recommended**: Test with 5+ providers - -### Property-Based Testing Opportunities - -1. **Message Token Estimation** (Priority: Low) - - **Property**: `estimate_tokens(msgs) <= actual_token_count(msgs) * 1.5` - - **Strategy**: Generate random messages, compare estimate to actual count from tiktoken - -2. **Session Serialization Roundtrip** (Priority: Medium) - - **Property**: `deserialize(serialize(conversation)) == conversation` - - **Strategy**: Use proptest to generate random Conversations - -3. **Theme Color Parsing** (Priority: Low) - - **Property**: `parse_color(color_to_string(c)) == c` for all valid colors - - **Strategy**: Test all Color variants - ---- - -## Documentation Improvements - -### Outdated Documentation - -1. **CLAUDE.md Claims "No Telemetry" but OAuth Flow Sends Metadata** (Priority: Medium) - - **Location**: Line 247-249 - - **Issue**: OAuth device flow sends client metadata to authorization server - - **Fix**: Clarify "No usage telemetry; OAuth metadata per spec" - -2. **Architecture Diagram Missing MCP Boundary** (Priority: High) - - **Location**: `docs/architecture.md` (if exists) or CLAUDE.md - - **Issue**: Diagram shows direct provider calls, not via MCP servers - - **Fix**: Update diagram to show MCP process boundaries - -3. **Config Migration Guide Incomplete** (Priority: Low) - - **Location**: CLAUDE.md mentions `config doctor` but doesn't explain what it fixes - - **Fix**: Document each migration (v1.0 → v1.5 → v1.9) with examples - -### Missing Explanations - -1. **No Explanation of Provider Type (Local vs Cloud)** (Priority: Medium) - - **Location**: `provider/types.rs:ProviderType` enum has no doc comment - - **Fix**: - ```rust - /// Classification of provider hosting model. - /// - /// - `Local`: Runs on user's machine (e.g., Ollama daemon, llama.cpp server) - /// - `Cloud`: Hosted API requiring network calls (e.g., Ollama Cloud, OpenAI) - pub enum ProviderType { - Local, - Cloud, - } - ``` - -2. **Session Compression Strategy Undocumented** (Priority: High) - - **Location**: `session.rs` - compression logic exists but no explanation - - **Fix**: Add module-level doc explaining sliding window, token budget, summarization - -3. **MCP Transport Selection Criteria** (Priority: Medium) - - **Issue**: When to use STDIO vs HTTP vs WebSocket not documented - - **Fix**: Add decision matrix to `docs/mcp-configuration.md` - -### Confusing Sections - -1. **"Dependency Boundaries" Section Contradicted by Cargo.toml** (Priority: Critical) - - **Location**: CLAUDE.md lines 14-16 - - **Issue**: Claims owlen-core is UI-agnostic but Cargo.toml shows ratatui dep - - **Fix**: Either fix code or update docs to reflect current state - -2. **Provider Implementation Guide References Removed Traits** (Priority: High) - - **Location**: `docs/provider-implementation.md` (if exists) - - **Issue**: May reference old `Provider` trait instead of current `ModelProvider` - - **Fix**: Audit and update to match current trait design - ---- - -## Positive Observations - -### Well-Designed Components - -1. **ProviderManager Health Tracking** (owlen-core/src/provider/manager.rs) - - Clean separation of concerns: manager orchestrates, providers implement - - FuturesUnordered for parallel health checks is excellent choice - - Status cache prevents redundant health checks - - **Exemplary Pattern**: Could be extracted as standalone health-check library - -2. **MCP Protocol Abstraction** (owlen-core/src/mcp/) - - Multiple transports (STDIO, HTTP, WebSocket) behind unified interface - - Proper JSON-RPC 2.0 implementation with request ID tracking - - Graceful notification skipping in response loop - - **Strong Foundation**: Easy to add gRPC or other transports - -3. **Theme System** (owlen-core/src/theme.rs) - - Rich palette (40+ customizable colors) - - Embedded TOML themes with runtime fallbacks - - Custom serialization for Color types - - **User-Friendly**: 12 built-in themes, easy to add custom - -4. **Test Infrastructure** (25+ test files) - - Integration tests use wiremock for HTTP mocking - - Test utilities in common/mod.rs for fixtures - - Mix of unit, integration, and snapshot tests - - **Good Coverage**: Core paths well-tested despite gaps identified above - -5. **Error Handling** (owlen-core/src/lib.rs) - - Custom Error enum with context-specific variants - - thiserror for ergonomic error definitions - - Proper error propagation with ? operator - - **Rust Best Practice**: Clear error messages, structured variants - -### Exemplary Code - -1. **RemoteMcpClient Transport Abstraction** (mcp/remote_client.rs:204-338) - - Single `send_rpc` method handles 3 transports - - Clear separation of concerns: serialize → transport → deserialize - - **Pattern to Replicate**: Other network clients should follow this design - -2. **Model Metadata Enrichment** (provider/manager.rs:224-265) - - Clever deduplication strategy (suffix local/cloud only when needed) - - Functional style with map/filter/collect - - **Well-Tested**: 3 unit tests cover edge cases - -3. **Async Trait Migration** (Multiple files) - - Proper use of `#[async_trait]` for provider traits - - BoxFuture for complex return types - - **Modern Rust**: Ready for trait_async_fn stabilization - -### Architectural Strengths - -1. **Workspace Structure** - - Logical separation: core, TUI, CLI, providers, MCP servers - - Shared workspace dependencies reduce version drift - - xtask for development automation (screenshots) - -2. **Multi-Provider Architecture** - - Extensible: Adding new provider only requires implementing ModelProvider - - Health tracking prevents cascading failures - - Clear metadata (Local vs Cloud) for UX decisions - -3. **MCP Integration** - - Process isolation for untrusted tools - - Spec-compliant JSON-RPC 2.0 - - Supports external servers via config - -4. **Configuration System** - - Schema versioning (`CONFIG_SCHEMA_VERSION`) - - Migration support (`config doctor`) - - Platform-specific paths (dirs crate) - - Environment variable overrides - -5. **Security Considerations** - - AES-GCM for session encryption - - Keyring integration for credential storage - - Path traversal checks (though needs improvement) - - No telemetry by default - ---- - -## Action Plan - -### Immediate (Next Sprint - 1-2 weeks) - -**Goal**: Address critical architectural violation and blocking operations - -1. **Extract TUI Dependencies from owlen-core** (P0) - - Create owlen-ui-common crate - - Move theme.rs and abstract Color type - - Update dependency graph - - Run full test suite - - **Success Criteria**: `cargo build -p owlen-core` completes without ratatui/crossterm - -2. **Fix WebSocket Blocking Constructor** (P0) - - Make `new_with_runtime` async - - Remove `block_in_place` wrapper - - Add connection timeout (30s default, configurable) - - Update all call sites - - **Success Criteria**: No block_in_place in remote_client.rs, connect timeout tested - -3. **Secure Path Traversal Checks** (P1) - - Implement `validate_safe_path()` with canonicalization - - Add URL decoding - - Test all attack vectors (URL encoding, symlinks, UNC paths) - - **Success Criteria**: All security tests pass, path validation documented - -4. **Update Critical Dependencies** (P1) - - Run `cargo update` for tokio, reqwest, serde, uuid, image - - Run `cargo audit` to verify no CVEs - - Test build on Windows, macOS, Linux - - **Success Criteria**: All tests pass, no audit warnings - -5. **Add Missing Provider Manager Tests** (P1) - - Test concurrent registration during listing - - Test health check failure transitions - - Test status cache invalidation - - **Success Criteria**: 90%+ coverage in provider/manager.rs - -**Estimated Effort**: 60-80 hours (1.5-2 weeks for one developer) - ---- - -### Short-Term (1-2 Sprints - 2-4 weeks) - -**Goal**: Improve performance and eliminate blocking operations - -1. **Replace All block_in_place Calls** (P1) - - Convert session.rs blocking_lock to async Mutex - - Fix chat_app.rs controller lock acquisition - - Audit codebase for any remaining blocking in async contexts - - **Success Criteria**: Zero block_in_place calls outside of CPU-bound work - -2. **Optimize ProviderManager Clone Overhead** (P1) - - Reduce String clones in refresh_health - - Consider DashMap for lock-free reads - - Profile before/after with 10 providers - - **Success Criteria**: 30% reduction in health check allocations - -3. **Add MCP RPC Timeouts** (P1) - - Wrap send_rpc in tokio::time::timeout - - Make timeout configurable per server - - Add retry logic with exponential backoff - - **Success Criteria**: Hung server test completes in <35s - -4. **Implement Circuit Breaker for Providers** (Architecture Recommendation #3) - - Add CircuitBreaker struct with failure thresholds - - Integrate with generate() and list_models() - - Add metrics (open/closed state transitions) - - **Success Criteria**: Failed provider fails fast after 3 consecutive errors - -5. **Audit and Document Rust 2024 Migration** (P1) - - Remove clippy::collapsible_if suppressions - - Refactor to let-chains where appropriate - - Update CI to enforce clean clippy - - **Success Criteria**: `cargo clippy --all -- -D warnings` passes - -**Estimated Effort**: 80-100 hours (2-2.5 weeks for one developer) - ---- - -### Long-Term (Roadmap - 1-3 months) - -**Goal**: Architectural improvements and performance optimization - -1. **Implement Message History Compression** (Optimization) - - Design sliding window compression strategy - - Add LLM-based summarization for old messages - - Integrate with SessionController - - Add configuration (compression threshold, window size) - - **Success Criteria**: 10K-message conversation uses <50MB memory - -2. **Provider Health Check Budget System** (Architecture Recommendation #2) - - Implement RateLimiter and ExponentialBackoff - - Integrate with ProviderManager - - Add observability (metrics, logs) - - **Success Criteria**: Health check rate <10/min/provider even under aggressive polling - -3. **Markdown Rendering Performance** (Optimization) - - Add criterion benchmarks for owlen-markdown - - Profile with flamegraph on 1000-line code blocks - - Optimize or move to spawn_blocking - - **Success Criteria**: 1000-line code block renders in <10ms - -4. **Comprehensive Security Audit** (Security) - - Hire external security firm or run bug bounty - - Audit encryption implementation (AES-GCM usage) - - Review credential storage (keyring integration) - - Test all MCP tool input validation - - **Success Criteria**: No critical/high severity findings - -5. **Provider Trait Version Negotiation** (Architecture Recommendation #4) - - Design ModelProviderV2 trait with version negotiation - - Add deprecation warnings for V1 - - Migrate built-in providers to V2 - - Document migration guide for external providers - - **Success Criteria**: All providers support V2, smooth migration path - -6. **Extract UI Abstractions** (Already in Immediate - expand here) - - After initial extraction, add shared widgets library - - Define common layout primitives - - Prepare for future GUI frontend (egui/iced) - - **Success Criteria**: Prototype egui frontend using owlen-ui-common - -**Estimated Effort**: 200-300 hours (2.5-4 months for one developer) - ---- - -## Appendix: Analysis Methodology - -### Tools Used - -1. **Code Reading**: Manual inspection of 132 Rust source files -2. **Grep Analysis**: Pattern matching for: - - `.unwrap()` and `.expect()` usage (error handling audit) - - `panic!` calls (crash path identification) - - `unsafe` blocks (memory safety review) - - `block_in_place` (async runtime violations) - - `TODO/FIXME` markers (incomplete work) - - Dependency imports (boundary violations) - -3. **Cargo Tooling**: - - `cargo tree` - dependency graph analysis - - `cargo clippy` - lint checking (simulated) - - `cargo outdated` - version checking (manual) - - `cargo audit` - CVE scanning (theoretical) - -4. **Static Analysis**: - - Rust 2024 edition feature usage - - Lock contention patterns (RwLock, Mutex) - - Clone operation frequency - - Test coverage estimation (file count heuristic) - -### Agents Consulted - -- **gemini-researcher**: (Not invoked due to time constraints, but recommended for:) - - ollama-rs changelog and breaking changes - - tokio-tungstenite migration guide 0.21 → 0.24 - - Rust async best practices 2024 - - RustSec advisory database queries - -### Analysis Scope - -**Covered**: -- All 11 workspace crates (owlen-core, owlen-tui, owlen-cli, owlen-providers, owlen-markdown, 5 MCP crates, xtask) -- Architecture adherence (dependency boundaries, async patterns) -- Security issues (path traversal, input validation, SQL injection surface area) -- Error handling patterns (unwrap, expect, panic) -- Test coverage (25+ test files identified) -- Performance patterns (clones, lock contention, blocking operations) -- Dependency versions (Cargo.toml analysis) - -**Not Covered** (Limitations): -- Dynamic analysis (no profiling, flamegraphs, or runtime metrics) -- Load testing (no stress tests executed) -- Cross-platform testing (only Linux environment analyzed) -- Security fuzzing (no AFL/libFuzzer runs) -- Dependency CVE deep-dive (cargo audit not executed) -- MCP server binary analysis (focused on client-side) -- GUI frontend exploration (TUI only) - -### Verification Steps - -1. ✓ Examined all workspace Cargo.toml files for dependency violations -2. ✓ Verified edition 2024 usage in root Cargo.toml -3. ✓ Traced provider trait implementations across crates -4. ✓ Checked MCP protocol implementation against JSON-RPC 2.0 spec -5. ✓ Reviewed path traversal checks in file operation tools -6. ✓ Identified blocking operations in async contexts -7. ✓ Surveyed test file locations and coverage areas -8. ✓ Analyzed error propagation patterns -9. ✓ Reviewed clone usage in hot paths (ProviderManager) -10. ✓ Checked for unsafe blocks and their justifications - -### Confidence Levels - -- **High Confidence** (90%+): Dependency boundary violation, blocking operations, path traversal weakness, unwrap/expect usage -- **Medium Confidence** (70-90%): Performance clone overhead, test coverage gaps, missing timeouts -- **Low Confidence** (<70%): Specific optimization gains (need profiling), deadlock potential (need dynamic analysis) - -### Time Investment - -- **Initial Survey**: 30 minutes (workspace structure, file count, dependency graph) -- **Core Analysis**: 3 hours (owlen-core, provider manager, MCP client, session controller) -- **TUI Analysis**: 1 hour (app event loop, chat_app patterns) -- **Security Review**: 1 hour (path validation, SQL queries, error handling) -- **Test Analysis**: 30 minutes (test file survey, coverage estimation) -- **Dependency Review**: 30 minutes (Cargo.toml versions, edition check) -- **Report Writing**: 2 hours (compilation, formatting, action plan) - -**Total**: ~8.5 hours of analysis - ---- - -**End of Report** - -For questions or follow-up analysis, please reference specific issue locations by file path and line number. All paths in this report are absolute from the repository root: `/home/cnachtigall/data/git/projects/Owlibou/owlen/` diff --git a/samples.json b/samples.json deleted file mode 100644 index 6cb700e..0000000 --- a/samples.json +++ /dev/null @@ -1 +0,0 @@ -[{"response": "first", "done": false}, {"response": "", "done": true, "final_data": {"prompt_eval_count": 2048, "eval_count": 512}}] diff --git a/scripts/check-windows.sh b/scripts/check-windows.sh deleted file mode 100644 index 5727fd5..0000000 --- a/scripts/check-windows.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if ! rustup target list --installed | grep -q "x86_64-pc-windows-gnu"; then - echo "Installing Windows GNU target..." - rustup target add x86_64-pc-windows-gnu -fi - -echo "Running cargo check for Windows (x86_64-pc-windows-gnu)..." -cargo check --target x86_64-pc-windows-gnu - -echo "Windows compatibility check completed successfully." diff --git a/scripts/gen-repo-map.sh b/scripts/gen-repo-map.sh deleted file mode 100755 index a54ff54..0000000 --- a/scripts/gen-repo-map.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -OUTPUT_PATH="${1:-${REPO_ROOT}/docs/repo-map.md}" - -if ! command -v tree >/dev/null 2>&1; then - echo "error: the 'tree' command is required to regenerate the repo map. Install it (e.g., 'sudo pacman -S tree') and re-run this script." >&2 - exit 1 -fi - -EXCLUDES='target|\\.git|\\.github|node_modules|dist|images|themes|dev|\\.venv' - -TMP_FILE="$(mktemp)" -trap 'rm -f "${TMP_FILE}"' EXIT - -pushd "${REPO_ROOT}" >/dev/null -tree -a -L 2 --dirsfirst --prune -I "${EXCLUDES}" > "${TMP_FILE}" -popd >/dev/null - -{ - printf '# Repo Map\n\n' - printf '> Generated by `scripts/gen-repo-map.sh`. Regenerate when the layout changes.\n\n' - printf '```text\n' - cat "${TMP_FILE}" - printf '```\n' -} > "${OUTPUT_PATH}" - -echo "Repo map written to ${OUTPUT_PATH}" diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh deleted file mode 100755 index 6f9094c..0000000 --- a/scripts/release-notes.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -CHANGELOG="${REPO_ROOT}/CHANGELOG.md" - -TAG="${1:-}" -OUTPUT="${2:-}" - -if [[ -z "${TAG}" ]]; then - echo "usage: $0 [output-file]" >&2 - exit 1 -fi - -TAG="${TAG#v}" -TAG="${TAG#V}" - -if [[ ! -f "${CHANGELOG}" ]]; then - echo "error: CHANGELOG.md not found at ${CHANGELOG}" >&2 - exit 1 -fi - -NOTES=$(TAG="${TAG}" CHANGELOG_PATH="${CHANGELOG}" python - <<'PY' -import os -import re -import sys -from pathlib import Path - -changelog_path = Path(os.environ['CHANGELOG_PATH']) -tag = os.environ['TAG'] -text = changelog_path.read_text(encoding='utf-8') -pattern = re.compile(rf'^## \[{re.escape(tag)}\]\s*(?:-.*)?$', re.MULTILINE) -match = pattern.search(text) -if not match: - sys.stderr.write(f"No changelog section found for tag {tag}.\n") - sys.exit(1) -start = match.end() -rest = text[start:] -next_heading = re.search(r'^## \[', rest, re.MULTILINE) -section = rest[:next_heading.start()] if next_heading else rest -lines = [line.rstrip() for line in section.strip().splitlines()] -print('\n'.join(lines)) -PY -) - -if [[ -z "${NOTES}" ]]; then - echo "error: no content generated for tag ${TAG}" >&2 - exit 1 -fi - -if [[ -n "${OUTPUT}" ]]; then - printf '%s\n' "${NOTES}" > "${OUTPUT}" -else - printf '%s\n' "${NOTES}" -fi diff --git a/themes/README.md b/themes/README.md deleted file mode 100644 index 1cb25e0..0000000 --- a/themes/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# OWLEN Built-in Themes - -This directory contains the built-in themes that are embedded into the OWLEN binary. - -## Available Themes - -- **default_dark** - High-contrast dark theme (default) -- **default_light** - Clean light theme -- **grayscale-high-contrast** - Monochrome palette tuned for color-blind accessibility -- **gruvbox** - Popular retro color scheme with warm tones -- **dracula** - Dark theme with vibrant purple and cyan colors -- **solarized** - Precision colors for optimal readability -- **midnight-ocean** - Deep blue oceanic theme -- **rose-pine** - Soho vibes with muted pastels -- **monokai** - Classic code editor theme -- **material-dark** - Google's Material Design dark variant -- **material-light** - Google's Material Design light variant - -## Theme File Format - -Each theme is defined in TOML format with the following structure: - -```toml -name = "theme-name" - -# Text colors -text = "#ffffff" # Main text color -placeholder = "#808080" # Placeholder/muted text - -# Background colors -background = "#000000" # Main background -command_bar_background = "#111111" -status_background = "#111111" - -# Border colors -focused_panel_border = "#ff00ff" # Active panel border -unfocused_panel_border = "#800080" # Inactive panel border - -# Message role colors -user_message_role = "#00ffff" # User messages -assistant_message_role = "#ffff00" # Assistant messages -thinking_panel_title = "#ff00ff" # Thinking panel title - -# Mode indicator colors (status bar) -mode_normal = "#00ffff" -mode_editing = "#00ff00" -mode_model_selection = "#ffff00" -mode_provider_selection = "#00ffff" -mode_help = "#ff00ff" -mode_visual = "#ff0080" -mode_command = "#ffff00" - -# Selection and cursor -selection_bg = "#0000ff" # Selection background -selection_fg = "#ffffff" # Selection foreground -cursor = "#ff0080" # Cursor color - -# Code block styling -code_block_background = "#111111" -code_block_border = "#ff00ff" -code_block_text = "#ffffff" -code_block_keyword = "#ffff00" -code_block_string = "#00ff00" -code_block_comment = "#808080" - -# Status colors -error = "#ff0000" # Error messages -info = "#00ff00" # Info/success messages -``` - -## Color Format - -Colors can be specified in two formats: - -1. **Hex RGB**: `#rrggbb` (e.g., `#ff0000` for red, `#ff8800` for orange) -2. **Named colors** (case-insensitive): - - **Basic**: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` - - **Gray variants**: `gray`, `grey`, `darkgray`, `darkgrey` - - **Light variants**: `lightred`, `lightgreen`, `lightyellow`, `lightblue`, `lightmagenta`, `lightcyan` - -**Note**: For colors not in the named list (like orange, purple, brown), use hex RGB format. - -OWLEN will display an error message on startup if a custom theme has invalid colors. - -## Creating Custom Themes - -To create your own theme: - -1. Copy one of these files to `~/.config/owlen/themes/` -2. Rename and modify the colors -3. Set `theme = "your-theme-name"` in `~/.config/owlen/config.toml` -4. Or use `:theme your-theme-name` in OWLEN to switch - -## Embedding in Binary - -These theme files are embedded into the OWLEN binary at compile time using Rust's `include_str!()` macro. This ensures they're always available, even if the files are deleted from disk. - -Custom themes placed in `~/.config/owlen/themes/` will override built-in themes with the same name. diff --git a/themes/ansi-basic.toml b/themes/ansi-basic.toml deleted file mode 100644 index cbf5165..0000000 --- a/themes/ansi-basic.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "ansi_basic" -text = "white" -background = "black" -focused_panel_border = "cyan" -unfocused_panel_border = "darkgray" -user_message_role = "cyan" -assistant_message_role = "yellow" -tool_output = "white" -thinking_panel_title = "magenta" -command_bar_background = "black" -status_background = "black" -mode_normal = "green" -mode_editing = "yellow" -mode_model_selection = "cyan" -mode_provider_selection = "magenta" -mode_help = "white" -mode_visual = "blue" -mode_command = "yellow" -selection_bg = "blue" -selection_fg = "white" -cursor = "white" -code_block_background = "black" -code_block_border = "cyan" -code_block_text = "white" -code_block_keyword = "yellow" -code_block_string = "green" -code_block_comment = "darkgray" -placeholder = "darkgray" -error = "red" -info = "green" diff --git a/themes/default_dark.toml b/themes/default_dark.toml deleted file mode 100644 index 0a63f83..0000000 --- a/themes/default_dark.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "default_dark" -text = "#e2e8f0" -background = "#020617" -focused_panel_border = "#7dd3fc" -unfocused_panel_border = "#1e293b" -user_message_role = "#38bdf8" -assistant_message_role = "#fbbf24" -tool_output = "#94a3b8" -thinking_panel_title = "#a855f7" -command_bar_background = "#0f172a" -status_background = "#111827" -mode_normal = "#38bdf8" -mode_editing = "#34d399" -mode_model_selection = "#fbbf24" -mode_provider_selection = "#22d3ee" -mode_help = "#a855f7" -mode_visual = "#f472b6" -mode_command = "#facc15" -selection_bg = "#1d4ed8" -selection_fg = "#f8fafc" -cursor = "#f472b6" -code_block_background = "#111827" -code_block_border = "#2563eb" -code_block_text = "#e2e8f0" -code_block_keyword = "#fbbf24" -code_block_string = "#34d399" -code_block_comment = "#64748b" -placeholder = "#64748b" -error = "#f87171" -info = "#38bdf8" diff --git a/themes/default_light.toml b/themes/default_light.toml deleted file mode 100644 index d9408b4..0000000 --- a/themes/default_light.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "default_light" -text = "#0f172a" -background = "#f8fafc" -focused_panel_border = "#2563eb" -unfocused_panel_border = "#c7d2fe" -user_message_role = "#2563eb" -assistant_message_role = "#9333ea" -tool_output = "#64748b" -thinking_panel_title = "#7c3aed" -command_bar_background = "#e2e8f0" -status_background = "#e0e7ff" -mode_normal = "#2563eb" -mode_editing = "#0ea5e9" -mode_model_selection = "#facc15" -mode_provider_selection = "#0ea5e9" -mode_help = "#7c3aed" -mode_visual = "#7c3aed" -mode_command = "#f97316" -selection_bg = "#bfdbfe" -selection_fg = "#0f172a" -cursor = "#f97316" -code_block_background = "#e2e8f0" -code_block_border = "#2563eb" -code_block_text = "#0f172a" -code_block_keyword = "#b45309" -code_block_string = "#15803d" -code_block_comment = "#94a3b8" -placeholder = "#64748b" -error = "#dc2626" -info = "#2563eb" diff --git a/themes/dracula.toml b/themes/dracula.toml deleted file mode 100644 index 2537f11..0000000 --- a/themes/dracula.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "dracula" -text = "#f8f8f2" -background = "#282a36" -focused_panel_border = "#ff79c6" -unfocused_panel_border = "#44475a" -user_message_role = "#8be9fd" -assistant_message_role = "#ff79c6" -tool_output = "#6272a4" -thinking_panel_title = "#bd93f9" -command_bar_background = "#44475a" -status_background = "#44475a" -mode_normal = "#8be9fd" -mode_editing = "#50fa7b" -mode_model_selection = "#f1fa8c" -mode_provider_selection = "#8be9fd" -mode_help = "#bd93f9" -mode_visual = "#ff79c6" -mode_command = "#f1fa8c" -selection_bg = "#44475a" -selection_fg = "#f8f8f2" -cursor = "#ff79c6" -code_block_background = "#44475a" -code_block_border = "#bd93f9" -code_block_text = "#f8f8f2" -code_block_keyword = "#ff79c6" -code_block_string = "#50fa7b" -code_block_comment = "#6272a4" -placeholder = "#6272a4" -error = "#ff5555" -info = "#50fa7b" diff --git a/themes/grayscale-high-contrast.toml b/themes/grayscale-high-contrast.toml deleted file mode 100644 index 74b434d..0000000 --- a/themes/grayscale-high-contrast.toml +++ /dev/null @@ -1,43 +0,0 @@ -name = "grayscale_high_contrast" -text = "#f7f7f7" -background = "#000000" -focused_panel_border = "#ffffff" -unfocused_panel_border = "#4c4c4c" -user_message_role = "#f0f0f0" -assistant_message_role = "#d6d6d6" -tool_output = "#bdbdbd" -thinking_panel_title = "#e0e0e0" -command_bar_background = "#000000" -status_background = "#0f0f0f" -mode_normal = "#ffffff" -mode_editing = "#e6e6e6" -mode_model_selection = "#cccccc" -mode_provider_selection = "#b3b3b3" -mode_help = "#999999" -mode_visual = "#f2f2f2" -mode_command = "#d0d0d0" -selection_bg = "#f0f0f0" -selection_fg = "#000000" -cursor = "#ffffff" -code_block_background = "#0f0f0f" -code_block_border = "#ffffff" -code_block_text = "#f7f7f7" -code_block_keyword = "#cccccc" -code_block_string = "#d6d6d6" -code_block_comment = "#7a7a7a" -placeholder = "#7a7a7a" -error = "#ffffff" -info = "#c8c8c8" -agent_thought = "#e6e6e6" -agent_action = "#cccccc" -agent_action_input = "#b0b0b0" -agent_observation = "#999999" -agent_final_answer = "#ffffff" -agent_badge_running_fg = "#000000" -agent_badge_running_bg = "#f7f7f7" -agent_badge_idle_fg = "#000000" -agent_badge_idle_bg = "#bdbdbd" -operating_chat_fg = "#000000" -operating_chat_bg = "#f2f2f2" -operating_code_fg = "#000000" -operating_code_bg = "#bfbfbf" diff --git a/themes/gruvbox.toml b/themes/gruvbox.toml deleted file mode 100644 index e8bd806..0000000 --- a/themes/gruvbox.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "gruvbox" -text = "#ebdbb2" -background = "#282828" -focused_panel_border = "#fe8019" -unfocused_panel_border = "#7c6f64" -user_message_role = "#b8bb26" -assistant_message_role = "#83a598" -tool_output = "#928374" -thinking_panel_title = "#d3869b" -command_bar_background = "#3c3836" -status_background = "#3c3836" -mode_normal = "#83a598" -mode_editing = "#b8bb26" -mode_model_selection = "#fabd2f" -mode_provider_selection = "#8ec07c" -mode_help = "#d3869b" -mode_visual = "#fe8019" -mode_command = "#fabd2f" -selection_bg = "#504945" -selection_fg = "#ebdbb2" -cursor = "#fe8019" -code_block_background = "#3c3836" -code_block_border = "#7c6f64" -code_block_text = "#ebdbb2" -code_block_keyword = "#fabd2f" -code_block_string = "#8ec07c" -code_block_comment = "#7c6f64" -placeholder = "#665c54" -error = "#fb4934" -info = "#b8bb26" diff --git a/themes/material-dark.toml b/themes/material-dark.toml deleted file mode 100644 index da082f8..0000000 --- a/themes/material-dark.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "material-dark" -text = "#eeffff" -background = "#263238" -focused_panel_border = "#80cbc4" -unfocused_panel_border = "#546e7a" -user_message_role = "#82aaff" -assistant_message_role = "#c792ea" -tool_output = "#546e7a" -thinking_panel_title = "#ffcb6b" -command_bar_background = "#212b30" -status_background = "#212b30" -mode_normal = "#82aaff" -mode_editing = "#c3e88d" -mode_model_selection = "#ffcb6b" -mode_provider_selection = "#80cbc4" -mode_help = "#c792ea" -mode_visual = "#f07178" -mode_command = "#ffcb6b" -selection_bg = "#546e7a" -selection_fg = "#eeffff" -cursor = "#ffcc00" -code_block_background = "#212b30" -code_block_border = "#80cbc4" -code_block_text = "#eeffff" -code_block_keyword = "#ffcb6b" -code_block_string = "#c3e88d" -code_block_comment = "#546e7a" -placeholder = "#546e7a" -error = "#f07178" -info = "#c3e88d" diff --git a/themes/material-light.toml b/themes/material-light.toml deleted file mode 100644 index 25bab66..0000000 --- a/themes/material-light.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "material-light" -text = "#212121" -background = "#eceff1" -focused_panel_border = "#009688" -unfocused_panel_border = "#b0bec5" -user_message_role = "#448aff" -assistant_message_role = "#7c4dff" -tool_output = "#90a4ae" -thinking_panel_title = "#f57c00" -command_bar_background = "#ffffff" -status_background = "#ffffff" -mode_normal = "#448aff" -mode_editing = "#388e3c" -mode_model_selection = "#f57c00" -mode_provider_selection = "#009688" -mode_help = "#7c4dff" -mode_visual = "#d32f2f" -mode_command = "#f57c00" -selection_bg = "#b0bec5" -selection_fg = "#212121" -cursor = "#c2185b" -code_block_background = "#f8f9fa" -code_block_border = "#009688" -code_block_text = "#212121" -code_block_keyword = "#f57c00" -code_block_string = "#388e3c" -code_block_comment = "#90a4ae" -placeholder = "#90a4ae" -error = "#d32f2f" -info = "#388e3c" diff --git a/themes/midnight-ocean.toml b/themes/midnight-ocean.toml deleted file mode 100644 index 331deef..0000000 --- a/themes/midnight-ocean.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "midnight-ocean" -text = "#c0caf5" -background = "#0d1117" -focused_panel_border = "#58a6ff" -unfocused_panel_border = "#30363d" -user_message_role = "#79c0ff" -assistant_message_role = "#89ddff" -tool_output = "#546e7a" -thinking_panel_title = "#9ece6a" -command_bar_background = "#161b22" -status_background = "#161b22" -mode_normal = "#79c0ff" -mode_editing = "#9ece6a" -mode_model_selection = "#ffd43b" -mode_provider_selection = "#89ddff" -mode_help = "#ff739d" -mode_visual = "#f68cf5" -mode_command = "#ffd43b" -selection_bg = "#388bfd" -selection_fg = "#0d1117" -cursor = "#f68cf5" -code_block_background = "#161b22" -code_block_border = "#58a6ff" -code_block_text = "#c0caf5" -code_block_keyword = "#ffd43b" -code_block_string = "#9ece6a" -code_block_comment = "#6e7681" -placeholder = "#6e7681" -error = "#f85149" -info = "#9ece6a" diff --git a/themes/monokai.toml b/themes/monokai.toml deleted file mode 100644 index 11889ed..0000000 --- a/themes/monokai.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "monokai" -text = "#f8f8f2" -background = "#272822" -focused_panel_border = "#f92672" -unfocused_panel_border = "#75715e" -user_message_role = "#66d9ef" -assistant_message_role = "#ae81ff" -tool_output = "#75715e" -thinking_panel_title = "#e6db74" -command_bar_background = "#272822" -status_background = "#272822" -mode_normal = "#66d9ef" -mode_editing = "#a6e22e" -mode_model_selection = "#e6db74" -mode_provider_selection = "#66d9ef" -mode_help = "#ae81ff" -mode_visual = "#f92672" -mode_command = "#e6db74" -selection_bg = "#75715e" -selection_fg = "#f8f8f2" -cursor = "#f92672" -code_block_background = "#32332e" -code_block_border = "#f92672" -code_block_text = "#f8f8f2" -code_block_keyword = "#e6db74" -code_block_string = "#a6e22e" -code_block_comment = "#75715e" -placeholder = "#75715e" -error = "#f92672" -info = "#a6e22e" diff --git a/themes/rose-pine.toml b/themes/rose-pine.toml deleted file mode 100644 index a6161ad..0000000 --- a/themes/rose-pine.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "rose-pine" -text = "#e0def4" -background = "#191724" -focused_panel_border = "#eb6f92" -unfocused_panel_border = "#26233a" -user_message_role = "#31748f" -assistant_message_role = "#9ccfd8" -tool_output = "#6e6a86" -thinking_panel_title = "#c4a7e7" -command_bar_background = "#26233a" -status_background = "#26233a" -mode_normal = "#9ccfd8" -mode_editing = "#ebbcba" -mode_model_selection = "#f6c177" -mode_provider_selection = "#31748f" -mode_help = "#c4a7e7" -mode_visual = "#eb6f92" -mode_command = "#f6c177" -selection_bg = "#403d52" -selection_fg = "#e0def4" -cursor = "#eb6f92" -code_block_background = "#26233a" -code_block_border = "#eb6f92" -code_block_text = "#e0def4" -code_block_keyword = "#f6c177" -code_block_string = "#9ccfd8" -code_block_comment = "#6e6a86" -placeholder = "#6e6a86" -error = "#eb6f92" -info = "#9ccfd8" diff --git a/themes/solarized.toml b/themes/solarized.toml deleted file mode 100644 index cd66e93..0000000 --- a/themes/solarized.toml +++ /dev/null @@ -1,30 +0,0 @@ -name = "solarized" -text = "#839496" -background = "#002b36" -focused_panel_border = "#268bd2" -unfocused_panel_border = "#073642" -user_message_role = "#2aa198" -assistant_message_role = "#cb4b16" -tool_output = "#657b83" -thinking_panel_title = "#6c71c4" -command_bar_background = "#073642" -status_background = "#073642" -mode_normal = "#268bd2" -mode_editing = "#859900" -mode_model_selection = "#b58900" -mode_provider_selection = "#2aa198" -mode_help = "#6c71c4" -mode_visual = "#d33682" -mode_command = "#b58900" -selection_bg = "#073642" -selection_fg = "#93a1a1" -cursor = "#d33682" -code_block_background = "#073642" -code_block_border = "#268bd2" -code_block_text = "#93a1a1" -code_block_keyword = "#b58900" -code_block_string = "#859900" -code_block_comment = "#586e75" -placeholder = "#586e75" -error = "#dc322f" -info = "#859900" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml deleted file mode 100644 index 94f53dc..0000000 --- a/xtask/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "xtask" -version = "0.1.0" -edition.workspace = true -publish = false - -[dependencies] -anyhow = { workspace = true } -clap = { workspace = true, features = ["derive"] } -crossterm = { workspace = true } -async-trait = { workspace = true } -futures-util = { workspace = true } -owlen-core = { path = "../crates/owlen-core" } -owlen-tui = { path = "../crates/owlen-tui" } -ratatui = { workspace = true } -serde_json = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true } -which = { workspace = true } diff --git a/xtask/src/main.rs b/xtask/src/main.rs deleted file mode 100644 index e5a46fd..0000000 --- a/xtask/src/main.rs +++ /dev/null @@ -1,186 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::process::Command; - -use anyhow::{Context, Result, bail}; -use clap::{Parser, Subcommand}; - -mod screenshots; - -#[derive(Parser)] -#[command(author, version, about = "Owlen developer tasks", long_about = None)] -struct Xtask { - #[command(subcommand)] - command: Task, -} - -#[derive(Subcommand)] -enum Task { - /// Format the workspace (use --check to verify without writing). - Fmt { - #[arg(long, help = "Run rustfmt in check mode")] - check: bool, - }, - /// Run clippy with all warnings elevated to errors. - Lint, - /// Execute the full workspace test suite. - Test, - /// Run coverage via cargo-llvm-cov (requires the tool to be installed). - Coverage, - /// Launch the default Owlen CLI binary (owlen) with optional args. - DevRun { - #[arg(last = true, help = "Arguments forwarded to `owlen`")] - args: Vec, - }, - /// Composite release validation (fmt --check, clippy, test). - ReleaseCheck, - /// Regenerate docs/repo-map.md (accepts optional output path). - GenRepoMap { - #[arg(long, value_name = "PATH", help = "Override the repo map output path")] - output: Option, - }, - /// Generate deterministic TUI screenshots and optional PNG renders. - Screenshots { - #[arg( - long, - value_name = "PATH", - help = "Output directory (defaults to images/generated)" - )] - output: Option, - #[arg(long, help = "Skip PNG conversion and only emit ANSI dumps")] - no_png: bool, - #[arg( - long, - value_name = "PATH", - help = "Path to chafa binary (default: chafa in PATH)" - )] - chafa: Option, - }, -} - -fn main() -> Result<()> { - let cli = Xtask::parse(); - - match cli.command { - Task::Fmt { check } => fmt(check), - Task::Lint => lint(), - Task::Test => test(), - Task::Coverage => coverage(), - Task::DevRun { args } => dev_run(args), - Task::ReleaseCheck => release_check(), - Task::GenRepoMap { output } => gen_repo_map(output), - Task::Screenshots { - output, - no_png, - chafa, - } => screenshots::run(output, chafa, no_png), - } -} - -fn fmt(check: bool) -> Result<()> { - let mut args = vec!["fmt".to_string(), "--all".to_string()]; - if check { - args.push("--".to_string()); - args.push("--check".to_string()); - } - run_cargo(args) -} - -fn lint() -> Result<()> { - run_cargo(vec![ - "clippy".into(), - "--workspace".into(), - "--all-features".into(), - "--".into(), - "-D".into(), - "warnings".into(), - ]) -} - -fn test() -> Result<()> { - run_cargo(vec![ - "test".into(), - "--workspace".into(), - "--all-features".into(), - ]) -} - -fn coverage() -> Result<()> { - run_cargo(vec![ - "llvm-cov".into(), - "--workspace".into(), - "--all-features".into(), - "--summary-only".into(), - ]) - .with_context(|| "install `cargo llvm-cov` to use the coverage task".to_string()) -} - -fn dev_run(args: Vec) -> Result<()> { - let mut command_args = vec![ - "run".into(), - "-p".into(), - "owlen-cli".into(), - "--bin".into(), - "owlen".into(), - ]; - if !args.is_empty() { - command_args.push("--".into()); - command_args.extend(args); - } - run_cargo(command_args) -} - -fn release_check() -> Result<()> { - fmt(true)?; - lint()?; - test()?; - Ok(()) -} - -fn gen_repo_map(output: Option) -> Result<()> { - let script = workspace_root().join("scripts/gen-repo-map.sh"); - if !script.exists() { - bail!("repo map script not found at {}", script.display()); - } - - let mut cmd = Command::new(&script); - cmd.current_dir(workspace_root()); - if let Some(path) = output { - cmd.arg(path); - } - let status = cmd - .status() - .with_context(|| format!("failed to run {}", script.display()))?; - if !status.success() { - bail!( - "{} exited with status {}", - script.display(), - status.code().unwrap_or_default() - ); - } - Ok(()) -} - -fn run_cargo(args: Vec) -> Result<()> { - let mut cmd = Command::new("cargo"); - cmd.current_dir(workspace_root()); - cmd.args(&args); - - let status = cmd - .status() - .with_context(|| format!("failed to run cargo {}", args.join(" ")))?; - if !status.success() { - bail!( - "`cargo {}` exited with status {}", - args.join(" "), - status.code().unwrap_or_default() - ); - } - Ok(()) -} - -pub(crate) fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("xtask has a parent directory") - .to_path_buf() -} diff --git a/xtask/src/screenshots.rs b/xtask/src/screenshots.rs deleted file mode 100644 index 726174b..0000000 --- a/xtask/src/screenshots.rs +++ /dev/null @@ -1,499 +0,0 @@ -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use anyhow::{Context, Result, bail}; -use async_trait::async_trait; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use futures_util::FutureExt; -use futures_util::future::BoxFuture; -use owlen_core::{ - Config, McpMode, Mode, Provider, - session::SessionController, - storage::StorageManager, - types::{Message, ModelInfo, ToolCall}, - ui::{NoOpUiController, UiController}, -}; -use owlen_tui::ChatApp; -use owlen_tui::events::Event; -use owlen_tui::ui::render_chat; -use ratatui::{Terminal, backend::TestBackend}; -use serde_json::json; -use tokio::runtime::Runtime; -use tokio::sync::mpsc; -use which::which; - -use crate::workspace_root; - -#[derive(Clone, Copy)] -struct SceneSpec { - name: &'static str, - width: u16, - height: u16, - builder: fn() -> BoxFuture<'static, Result>, -} - -pub(crate) fn run(output: Option, chafa: Option, no_png: bool) -> Result<()> { - let output_dir = output.unwrap_or_else(|| workspace_root().join("images/generated")); - fs::create_dir_all(&output_dir) - .with_context(|| format!("failed to create output directory {}", output_dir.display()))?; - - let config_home = workspace_root().join("dev/xtask-config"); - fs::create_dir_all(&config_home).with_context(|| { - format!( - "failed to create config home directory {}", - config_home.display() - ) - })?; - unsafe { - // Safe: we control the value and keep it within the workspace for deterministic snapshots. - env::set_var("XDG_CONFIG_HOME", &config_home); - env::set_var("XDG_DATA_HOME", &config_home); - } - - let png_requested = !no_png; - let mut png_skip_reason: Option = None; - let chafa_binary: Option = if png_requested { - let candidate = if let Some(ref path) = chafa { - if path.exists() { - Some(path.clone()) - } else { - bail!("specified chafa binary '{}' does not exist", path.display()); - } - } else { - which("chafa").ok() - }; - - if let Some(path) = candidate { - match chafa_supports_save(&path) { - Ok(true) => Some(path), - Ok(false) => { - png_skip_reason = Some(format!( - "chafa at '{}' lacks --save support; install chafa 1.14+ or rerun with --no-png", - path.display() - )); - None - } - Err(err) => { - eprintln!("warning: failed to probe chafa capabilities: {err}"); - png_skip_reason = Some("failed to verify chafa capabilities".to_string()); - None - } - } - } else { - png_skip_reason = - Some("chafa not found in PATH; install it or rerun with --no-png".to_string()); - None - } - } else { - None - }; - - let runtime = Runtime::new().context("failed to create tokio runtime")?; - - for scene in scenes() { - let mut app = runtime.block_on((scene.builder)())?; - let ansi = render_snapshot(&mut app, scene.width, scene.height)?; - let ans_path = output_dir.join(format!("{}.ans", scene.name)); - fs::write(&ans_path, ansi.as_bytes()) - .with_context(|| format!("failed to write ANSI dump {}", ans_path.display()))?; - - if let Some(ref binary) = chafa_binary - && let Err(err) = convert_to_png( - &ans_path, - scene.width, - scene.height, - &output_dir, - scene.name, - binary.as_path(), - ) - { - eprintln!("warning: {}", err); - } - } - - println!("Screenshots written to {}", output_dir.display()); - if png_requested && chafa_binary.is_none() { - if let Some(reason) = png_skip_reason { - println!("PNG conversion skipped ({reason})"); - } else { - println!("PNG conversion skipped"); - } - } else if !png_requested { - println!("PNG conversion skipped (use --no-png=false or omit flag to enable)"); - } - - Ok(()) -} - -fn convert_to_png( - ans_path: &Path, - width: u16, - height: u16, - output_dir: &Path, - name: &str, - chafa_path: &Path, -) -> Result<()> { - let png_path = output_dir.join(format!("{}.png", name)); - let mut command = Command::new(chafa_path); - let status = command - .arg("--size") - .arg(format!("{}x{}", width, height)) - .arg("--save") - .arg(&png_path) - .arg(ans_path) - .status() - .with_context(|| "failed to spawn chafa".to_string())?; - if !status.success() { - bail!( - "chafa exited with status {} when rendering {}", - status.code().unwrap_or_default(), - name - ); - } - Ok(()) -} - -fn chafa_supports_save(chafa_path: &Path) -> Result { - let output = Command::new(chafa_path) - .arg("--help") - .output() - .with_context(|| { - format!( - "failed to run '{}' to probe capabilities", - chafa_path.display() - ) - })?; - let help = String::from_utf8_lossy(&output.stdout); - Ok(help.contains("--save")) -} - -fn scenes() -> &'static [SceneSpec] { - &[ - SceneSpec { - name: "chat-idle-dark", - width: 120, - height: 32, - builder: || async move { Ok(build_chat_app(|_| {}, |_| {}).await) }.boxed(), - }, - SceneSpec { - name: "chat-tool-call", - width: 120, - height: 32, - builder: || { - async move { - let app = build_chat_app( - |_| {}, - |session| { - let conversation = session.conversation_mut(); - conversation.push_user_message("What happened in the Rust ecosystem today?"); - let stream_id = conversation.start_streaming_response(); - conversation - .set_stream_placeholder(stream_id, "Consulting the knowledge base…") - .expect("placeholder"); - let tool_call = ToolCall { - id: "call-search-1".into(), - name: "web_search".into(), - arguments: json!({ "query": "Rust language news" }), - }; - conversation - .set_tool_calls_on_message(stream_id, vec![tool_call.clone()]) - .expect("tool call metadata"); - conversation - .append_stream_chunk(stream_id, "Found multiple articles…", false) - .expect("stream chunk"); - conversation.push_message(Message::tool( - tool_call.id.clone(), - "Rust 1.85 released with generics cleanups and faster async compilation.".into(), - )); - conversation.push_message(Message::assistant( - "Summarising the latest Rust release and the async runtime updates.".into(), - )); - }, - ) - .await; - Ok(app) - } - .boxed() - }, - }, - SceneSpec { - name: "command-palette", - width: 110, - height: 28, - builder: || { - async move { - let mut app = build_chat_app(|_| {}, |_| {}).await; - send_key(&mut app, KeyCode::Char(':'), KeyModifiers::NONE).await; - type_text(&mut app, "focus").await; - send_key(&mut app, KeyCode::Down, KeyModifiers::NONE).await; - Ok(app) - } - .boxed() - }, - }, - SceneSpec { - name: "guidance-onboarding", - width: 100, - height: 28, - builder: || { - async move { - let app = build_chat_app( - |cfg| { - cfg.ui.show_onboarding = true; - cfg.ui.guidance.coach_marks_complete = false; - }, - |_| {}, - ) - .await; - Ok(app) - } - .boxed() - }, - }, - SceneSpec { - name: "guidance-cheatsheet", - width: 120, - height: 30, - builder: || { - async move { - let mut app = - build_chat_app(|cfg| cfg.ui.guidance.coach_marks_complete = true, |_| {}) - .await; - send_key(&mut app, KeyCode::Char('?'), KeyModifiers::NONE).await; - Ok(app) - } - .boxed() - }, - }, - SceneSpec { - name: "editing-mode", - width: 120, - height: 32, - builder: || { - async move { - let mut app = build_chat_app(|_| {}, |_| {}).await; - send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await; - type_text(&mut app, "Editing mode demonstration").await; - Ok(app) - } - .boxed() - }, - }, - SceneSpec { - name: "visual-mode", - width: 120, - height: 32, - builder: || { - async move { - let mut app = build_chat_app( - |_| {}, - |session| { - let conversation = session.conversation_mut(); - conversation.push_user_message( - "Render visual selection across multiple lines.", - ); - conversation.push_message(Message::assistant( - "Assistant reply for visual mode highlighting.".into(), - )); - }, - ) - .await; - send_key(&mut app, KeyCode::Char('v'), KeyModifiers::NONE).await; - send_key(&mut app, KeyCode::Char('j'), KeyModifiers::NONE).await; - send_key(&mut app, KeyCode::Char('j'), KeyModifiers::NONE).await; - Ok(app) - } - .boxed() - }, - }, - SceneSpec { - name: "accessibility-high-contrast", - width: 120, - height: 32, - builder: || { - async move { - let app = build_chat_app( - |cfg| { - cfg.ui.accessibility.high_contrast = true; - cfg.ui.guidance.coach_marks_complete = true; - }, - |_| {}, - ) - .await; - Ok(app) - } - .boxed() - }, - }, - SceneSpec { - name: "accessibility-reduced-chrome", - width: 120, - height: 32, - builder: || { - async move { - let app = build_chat_app( - |cfg| { - cfg.ui.accessibility.reduced_chrome = true; - cfg.ui.guidance.coach_marks_complete = true; - }, - |_| {}, - ) - .await; - Ok(app) - } - .boxed() - }, - }, - SceneSpec { - name: "emacs-profile", - width: 120, - height: 32, - builder: || { - async move { - let mut app = build_chat_app( - |cfg| { - cfg.ui.keymap_profile = Some("emacs".into()); - cfg.ui.guidance.coach_marks_complete = true; - }, - |_| {}, - ) - .await; - send_key(&mut app, KeyCode::Char(':'), KeyModifiers::NONE).await; - type_text(&mut app, "help").await; - Ok(app) - } - .boxed() - }, - }, - ] -} - -fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> Result { - let backend = TestBackend::new(width, height); - let mut terminal = Terminal::new(backend).context("failed to create test terminal")?; - terminal - .draw(|frame| render_chat(frame, app)) - .context("failed to render chat frame")?; - let buffer = terminal.backend().buffer(); - Ok(buffer_to_string(buffer)) -} - -fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String { - let mut output = String::new(); - for y in 0..buffer.area.height { - for x in 0..buffer.area.width { - output.push_str(buffer[(x, y)].symbol()); - } - output.push('\n'); - } - output -} - -async fn send_key(app: &mut ChatApp, code: KeyCode, modifiers: KeyModifiers) { - app.handle_event(Event::Key(KeyEvent::new(code, modifiers))) - .await - .expect("send key event"); -} - -async fn type_text(app: &mut ChatApp, text: &str) { - for ch in text.chars() { - send_key(app, KeyCode::Char(ch), KeyModifiers::NONE).await; - } -} - -async fn build_chat_app(configure_config: C, configure_session: F) -> ChatApp -where - C: FnOnce(&mut Config) + Send + 'static, - F: FnOnce(&mut SessionController) + Send + 'static, -{ - let temp_dir = tempfile::tempdir().expect("temp dir"); - let storage = - StorageManager::with_database_path(temp_dir.path().join("owlen-tui-screenshots.db")) - .await - .expect("storage"); - let storage = std::sync::Arc::new(storage); - - let mut config = Config::default(); - configure_config(&mut config); - config.general.default_model = Some("stub-model".into()); - config.general.enable_streaming = true; - config.privacy.encrypt_local_data = false; - config.privacy.require_consent_per_session = false; - config.ui.show_timestamps = false; - config.mcp.mode = McpMode::LocalOnly; - config.mcp.allow_fallback = false; - config.mcp.warn_on_legacy = false; - - let provider: std::sync::Arc = std::sync::Arc::new(StubProvider); - let ui: std::sync::Arc = std::sync::Arc::new(NoOpUiController); - let (event_tx, controller_event_rx) = mpsc::unbounded_channel(); - - let mut session = SessionController::new(provider, config, storage, ui, true, Some(event_tx)) - .await - .expect("session controller"); - - session - .set_operating_mode(Mode::Chat) - .await - .expect("chat mode"); - - configure_session(&mut session); - - let (app, mut session_rx) = ChatApp::new(session, controller_event_rx) - .await - .expect("chat app"); - session_rx.close(); - - app -} - -#[derive(Default)] -struct StubProvider; - -#[async_trait] -impl Provider for StubProvider { - fn name(&self) -> &str { - "stub-provider" - } - - async fn list_models(&self) -> owlen_core::Result> { - Ok(vec![ModelInfo { - id: "stub-model".into(), - name: "Stub Model".into(), - description: Some("Stub model for screenshot generation".into()), - provider: self.name().into(), - context_window: Some(8_192), - capabilities: vec!["chat".into(), "tool-use".into()], - supports_tools: true, - }]) - } - - async fn send_prompt( - &self, - _request: owlen_core::types::ChatRequest, - ) -> owlen_core::Result { - Ok(owlen_core::types::ChatResponse { - message: Message::assistant("stub completion".into()), - usage: None, - is_streaming: false, - is_final: true, - }) - } - - async fn stream_prompt( - &self, - _request: owlen_core::types::ChatRequest, - ) -> owlen_core::Result { - Ok(Box::pin(futures_util::stream::empty())) - } - - async fn health_check(&self) -> owlen_core::Result<()> { - Ok(()) - } - - fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) { - self - } -}