From 56a99b24ea9872cb2d3e91580b5f2753ee2136d4 Mon Sep 17 00:00:00 2001 From: untra Date: Sat, 7 Mar 2026 15:27:46 -0700 Subject: [PATCH 1/6] pipelines cleanup, a variety of work done last week committed today vscode extension adjustments refactor app.rs vast structured UI from the backstage-server icons defined and structured in the UI, and swagger cmux support zellij, rename session window context pane, stricter lint rules test matrix Co-Authored-By: Claude Opus 4.5 --- .eslintrc.base.json | 2 +- .github/workflows/build.yaml | 3 +- .../workflows/integration-tests-matrix.yml | 184 ++ Cargo.lock | 184 ++ Cargo.toml | 69 + azure-pipelines.yml | 1 + bindings/ActiveAgentResponse.ts | 2 +- bindings/AgentState.ts | 28 +- bindings/BackstageConfig.ts | 4 +- bindings/CmuxPlacementPolicy.ts | 6 + bindings/Config.ts | 2 +- bindings/Delegator.ts | 2 +- bindings/DetectedTool.ts | 2 +- bindings/ExecutionProcess.ts | 2 +- bindings/ExternalIssueTypeSummary.ts | 22 + bindings/GitHubConfig.ts | 2 +- bindings/GitLabConfig.ts | 2 +- bindings/JiraConfig.ts | 4 +- bindings/LaunchTicketRequest.ts | 2 +- bindings/LaunchTicketResponse.ts | 12 +- bindings/LinearConfig.ts | 4 +- bindings/LlmToolsConfig.ts | 2 +- bindings/McpDescriptorResponse.ts | 30 + bindings/OperatorOutput.ts | 4 +- bindings/OsNotificationConfig.ts | 4 +- bindings/ProjectSummary.ts | 2 +- bindings/ProjectSyncConfig.ts | 9 +- bindings/Session.ts | 8 +- bindings/SessionWrapperType.ts | 2 +- bindings/SessionsCmuxConfig.ts | 19 + bindings/SessionsConfig.ts | 16 +- bindings/SessionsZellijConfig.ts | 10 + bindings/State.ts | 2 +- bindings/StepAttempt.ts | 10 +- bindings/StepCompleteRequest.ts | 4 +- bindings/StepCompleteResponse.ts | 12 +- bindings/TemplatesConfig.ts | 2 +- bindings/VsCodeLaunchOptions.ts | 2 +- clippy.toml | 5 + docs/_data/navigation.yml | 3 + docs/_includes/footer.html | 7 + docs/_layouts/default.html | 2 + docs/agents/artifact-detection.md | 125 + docs/cli/index.md | 4 + docs/configuration/index.md | 58 +- docs/getting-started/sessions/cmux.md | 132 + docs/getting-started/sessions/index.md | 6 + docs/getting-started/sessions/zellij.md | 119 + docs/schemas/config.json | 199 +- docs/schemas/config.md | 263 +- docs/schemas/issuetype.md | 10 +- docs/schemas/metadata.md | 2 +- docs/schemas/openapi.json | 521 ++- docs/schemas/state.json | 42 +- docs/schemas/state.md | 50 +- docs/shortcuts/index.md | 8 +- docs/terms-of-service.md | 165 + scripts/ci/run-zellij-integration.sh | 42 + shared/types.ts | 206 +- skills/step.md | 28 + src/agents/activity.rs | 378 ++- src/agents/agent_switcher.rs | 473 +++ src/agents/artifact_detector.rs | 246 ++ src/agents/cmux.rs | 1196 +++++++ src/agents/generator.rs | 36 +- src/agents/hooks/mod.rs | 18 +- src/agents/idle_detector.rs | 4 +- src/agents/launcher/cmux_session.rs | 415 +++ src/agents/launcher/interpolation.rs | 14 +- src/agents/launcher/llm_command.rs | 13 +- src/agents/launcher/mod.rs | 301 +- src/agents/launcher/prompt.rs | 28 +- src/agents/launcher/step_config.rs | 4 +- src/agents/launcher/tmux_session.rs | 38 +- src/agents/launcher/worktree_setup.rs | 12 +- src/agents/launcher/zellij_session.rs | 335 ++ src/agents/mod.rs | 24 +- src/agents/monitor.rs | 346 +- src/agents/pr_workflow.rs | 7 +- src/agents/session.rs | 2 +- src/agents/sync.rs | 183 +- src/agents/terminal_wrapper.rs | 25 +- src/agents/tmux.rs | 60 +- src/agents/vscode_types.rs | 6 +- src/agents/zellij.rs | 805 +++++ src/api/anthropic.rs | 11 +- src/api/error.rs | 19 +- src/api/gh_cli.rs | 8 +- src/api/github.rs | 37 +- src/api/github_service.rs | 5 +- src/api/mod.rs | 10 +- src/api/pr_service.rs | 4 +- src/api/providers/ai/anthropic.rs | 4 +- src/api/providers/ai/mod.rs | 12 +- src/api/providers/kanban/jira.rs | 52 +- src/api/providers/kanban/linear.rs | 58 +- src/api/providers/kanban/mod.rs | 58 +- src/api/providers/mod.rs | 2 +- src/api/providers/repo/github.rs | 4 +- src/api/providers/repo/mod.rs | 11 +- src/app.rs | 2943 ----------------- src/app/agents.rs | 388 +++ src/app/data_sync.rs | 213 ++ src/app/kanban.rs | 138 + src/app/keyboard.rs | 467 +++ src/app/mod.rs | 453 +++ src/app/pr_workflow.rs | 339 ++ src/app/review.rs | 77 + src/app/session.rs | 151 + src/app/tests.rs | 782 +++++ src/app/tickets.rs | 381 +++ src/backstage/analyzer.rs | 22 +- src/backstage/mod.rs | 3 +- src/backstage/scaffold.rs | 40 +- src/backstage/server.rs | 5 +- src/backstage/taxonomy.rs | 10 +- src/bin/generate_types.rs | 43 +- src/collections/backstage_full/FEAT.json | 1 + src/collections/dev_kanban/FEAT.json | 1 + src/config.rs | 302 +- src/docs_gen/cli.rs | 56 +- src/docs_gen/config.rs | 16 +- src/docs_gen/config_schema.rs | 2 +- src/docs_gen/issuetype.rs | 10 +- src/docs_gen/jira_api.rs | 6 +- src/docs_gen/llm_tools.rs | 2 +- src/docs_gen/markdown.rs | 14 +- src/docs_gen/metadata.rs | 14 +- src/docs_gen/mod.rs | 4 +- src/docs_gen/openapi.rs | 2 +- src/docs_gen/state_schema.rs | 2 +- src/docs_gen/taxonomy.rs | 2 +- src/env_vars.rs | 2 +- src/issuetypes/collection.rs | 9 +- src/issuetypes/loader.rs | 15 +- src/issuetypes/mod.rs | 29 +- src/issuetypes/schema.rs | 20 +- src/lib.rs | 9 +- src/llm/detection.rs | 1 + src/llm/mod.rs | 3 + src/llm/skill_deployer.rs | 294 ++ src/llm/tool_config.rs | 6 +- src/logging.rs | 2 +- src/main.rs | 62 +- src/mcp/descriptor.rs | 87 + src/mcp/mod.rs | 9 + src/mcp/tools.rs | 272 ++ src/mcp/transport.rs | 371 +++ src/notifications/integration.rs | 1 + src/notifications/mod.rs | 37 +- src/notifications/os_integration.rs | 3 +- src/notifications/service.rs | 3 + src/notifications/webhook_integration.rs | 4 + src/permissions/claude.rs | 4 +- src/permissions/codex.rs | 8 +- src/permissions/gemini.rs | 12 +- src/permissions/mod.rs | 8 +- src/permissions/translator.rs | 23 +- src/pr_config/mod.rs | 8 +- src/projects.rs | 10 +- src/queue/creator.rs | 12 +- src/queue/mod.rs | 6 +- src/queue/ticket.rs | 170 +- src/queue/watcher.rs | 2 +- src/rest/dto.rs | 114 +- src/rest/error.rs | 2 +- src/rest/mod.rs | 23 +- src/rest/openapi.rs | 6 + src/rest/routes/agents.rs | 22 +- src/rest/routes/collections.rs | 4 +- src/rest/routes/delegators.rs | 10 +- src/rest/routes/issuetypes.rs | 34 +- src/rest/routes/kanban.rs | 56 + src/rest/routes/launch.rs | 15 +- src/rest/routes/mod.rs | 1 + src/rest/routes/projects.rs | 11 +- src/rest/routes/queue.rs | 14 +- src/rest/routes/skills.rs | 2 +- src/rest/routes/steps.rs | 28 +- src/rest/state.rs | 6 +- src/schemas/issuetype_schema.json | 5 + src/services/kanban_sync.rs | 32 +- src/services/pr_monitor.rs | 2 +- src/setup.rs | 5 +- src/startup/mod.rs | 2 + src/startup/templates.rs | 4 +- src/state.rs | 289 +- src/steps/manager.rs | 23 +- src/steps/mod.rs | 2 +- src/steps/session.rs | 25 +- src/templates/mod.rs | 15 +- src/templates/schema.rs | 180 +- src/types/attempt.rs | 97 +- src/types/pr.rs | 4 +- src/ui/collection_dialog.rs | 4 +- src/ui/create_dialog.rs | 29 +- src/ui/dialogs/confirm.rs | 37 +- src/ui/dialogs/help.rs | 6 + src/ui/dialogs/mod.rs | 44 + src/ui/dialogs/session_recovery.rs | 4 +- src/ui/dialogs/sync_confirm.rs | 6 +- src/ui/form_field.rs | 19 +- src/ui/kanban_view.rs | 2 +- src/ui/keybindings.rs | 6 +- src/ui/paginated_list.rs | 2 +- src/ui/panels.rs | 44 +- src/ui/projects_dialog.rs | 6 +- src/ui/session_preview.rs | 5 + src/ui/setup/mod.rs | 52 +- src/ui/setup/steps/collection.rs | 4 +- src/ui/setup/steps/confirm.rs | 6 +- src/ui/setup/steps/kanban.rs | 12 +- src/ui/setup/steps/startup.rs | 2 +- src/ui/setup/steps/task_fields.rs | 2 +- src/ui/setup/steps/welcome.rs | 12 +- src/ui/setup/steps/wrapper.rs | 211 +- src/ui/setup/types.rs | 27 +- src/version.rs | 5 +- tests/launch_common/mod.rs | 446 +++ tests/launch_integration.rs | 428 +-- tests/launch_integration_cmux.rs | 305 ++ tests/launch_integration_vscode.rs | 277 ++ tests/launch_integration_zellij.rs | 380 +++ vscode-extension/.eslintrc.json | 9 +- vscode-extension/.fantasticonrc.js | 11 + vscode-extension/.vscodeignore | 4 + vscode-extension/docs/schemas/config.json | 1473 +++++++++ vscode-extension/docs/schemas/jira-api.json | 205 ++ vscode-extension/docs/schemas/state.json | 442 +++ vscode-extension/images/icons/atlassian.svg | 1 + vscode-extension/images/icons/claude.svg | 1 + vscode-extension/images/icons/codex.svg | 1 + vscode-extension/images/icons/gemini.svg | 1 + vscode-extension/images/icons/linear.svg | 1 + vscode-extension/package-lock.json | 322 +- vscode-extension/package.json | 70 +- vscode-extension/shared/types.ts | 1214 +++++++ vscode-extension/src/api-client.ts | 175 +- vscode-extension/src/config-panel.ts | 221 +- vscode-extension/src/extension.ts | 222 +- vscode-extension/src/issuetype-service.ts | 8 +- vscode-extension/src/kanban-onboarding.ts | 121 +- vscode-extension/src/launch-dialog.ts | 2 +- vscode-extension/src/launch-manager.ts | 14 +- vscode-extension/src/mcp-connect.ts | 118 + vscode-extension/src/operator-binary.ts | 17 +- vscode-extension/src/status-provider.ts | 219 +- vscode-extension/src/terminal-manager.ts | 16 +- vscode-extension/src/ticket-parser.ts | 6 +- vscode-extension/src/walkthrough.ts | 65 +- vscode-extension/src/webhook-server.ts | 18 +- .../fixtures/api/mcp-descriptor-response.json | 8 + vscode-extension/test/runTest.ts | 2 +- .../test/suite/api-client.test.ts | 92 +- vscode-extension/test/suite/extension.test.ts | 2 +- vscode-extension/test/suite/index.ts | 64 +- .../test/suite/integration.test.ts | 16 +- .../test/suite/issuetype-service.test.ts | 10 +- .../test/suite/mcp-connect.test.ts | 193 ++ .../test/suite/walkthrough.test.ts | 22 +- vscode-extension/tsconfig.json | 2 + .../walkthrough/select-directory.md | 6 +- vscode-extension/webpack.webview.config.js | 7 + vscode-extension/webview-ui/App.tsx | 186 +- .../webview-ui/components/ConfigPage.tsx | 74 +- .../kanban/CollectionsSubSection.tsx | 140 + .../components/kanban/FieldEditor.tsx | 140 + .../components/kanban/IssueTypeDrawer.tsx | 420 +++ .../components/kanban/MappingPanel.tsx | 121 + .../components/kanban/MappingRow.tsx | 107 + .../components/kanban/ProjectRow.tsx | 142 + .../components/kanban/ProviderCard.tsx | 284 ++ .../components/kanban/StepEditor.tsx | 173 + .../components/kanban/WorkflowPreview.tsx | 159 + .../sections/CodingAgentsSection.tsx | 7 + .../sections/GitRepositoriesSection.tsx | 2 +- .../sections/KanbanProvidersSection.tsx | 470 +-- vscode-extension/webview-ui/index.tsx | 1 + vscode-extension/webview-ui/types/defaults.ts | 8 + vscode-extension/webview-ui/types/messages.ts | 30 +- 280 files changed, 22511 insertions(+), 5099 deletions(-) create mode 100644 bindings/CmuxPlacementPolicy.ts create mode 100644 bindings/ExternalIssueTypeSummary.ts create mode 100644 bindings/McpDescriptorResponse.ts create mode 100644 bindings/SessionsCmuxConfig.ts create mode 100644 bindings/SessionsZellijConfig.ts create mode 100644 clippy.toml create mode 100644 docs/_includes/footer.html create mode 100644 docs/agents/artifact-detection.md create mode 100644 docs/getting-started/sessions/cmux.md create mode 100644 docs/getting-started/sessions/zellij.md create mode 100644 docs/terms-of-service.md create mode 100755 scripts/ci/run-zellij-integration.sh create mode 100644 skills/step.md create mode 100644 src/agents/agent_switcher.rs create mode 100644 src/agents/artifact_detector.rs create mode 100644 src/agents/cmux.rs create mode 100644 src/agents/launcher/cmux_session.rs create mode 100644 src/agents/launcher/zellij_session.rs create mode 100644 src/agents/zellij.rs delete mode 100644 src/app.rs create mode 100644 src/app/agents.rs create mode 100644 src/app/data_sync.rs create mode 100644 src/app/kanban.rs create mode 100644 src/app/keyboard.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/pr_workflow.rs create mode 100644 src/app/review.rs create mode 100644 src/app/session.rs create mode 100644 src/app/tests.rs create mode 100644 src/app/tickets.rs create mode 100644 src/llm/skill_deployer.rs create mode 100644 src/mcp/descriptor.rs create mode 100644 src/mcp/mod.rs create mode 100644 src/mcp/tools.rs create mode 100644 src/mcp/transport.rs create mode 100644 src/rest/routes/kanban.rs create mode 100644 tests/launch_common/mod.rs create mode 100644 tests/launch_integration_cmux.rs create mode 100644 tests/launch_integration_vscode.rs create mode 100644 tests/launch_integration_zellij.rs create mode 100644 vscode-extension/.fantasticonrc.js create mode 100644 vscode-extension/docs/schemas/config.json create mode 100644 vscode-extension/docs/schemas/jira-api.json create mode 100644 vscode-extension/docs/schemas/state.json create mode 100644 vscode-extension/images/icons/atlassian.svg create mode 100644 vscode-extension/images/icons/claude.svg create mode 100644 vscode-extension/images/icons/codex.svg create mode 100644 vscode-extension/images/icons/gemini.svg create mode 100644 vscode-extension/images/icons/linear.svg create mode 100644 vscode-extension/shared/types.ts create mode 100644 vscode-extension/src/mcp-connect.ts create mode 100644 vscode-extension/test/fixtures/api/mcp-descriptor-response.json create mode 100644 vscode-extension/test/suite/mcp-connect.test.ts create mode 100644 vscode-extension/webview-ui/components/kanban/CollectionsSubSection.tsx create mode 100644 vscode-extension/webview-ui/components/kanban/FieldEditor.tsx create mode 100644 vscode-extension/webview-ui/components/kanban/IssueTypeDrawer.tsx create mode 100644 vscode-extension/webview-ui/components/kanban/MappingPanel.tsx create mode 100644 vscode-extension/webview-ui/components/kanban/MappingRow.tsx create mode 100644 vscode-extension/webview-ui/components/kanban/ProjectRow.tsx create mode 100644 vscode-extension/webview-ui/components/kanban/ProviderCard.tsx create mode 100644 vscode-extension/webview-ui/components/kanban/StepEditor.tsx create mode 100644 vscode-extension/webview-ui/components/kanban/WorkflowPreview.tsx diff --git a/.eslintrc.base.json b/.eslintrc.base.json index bc04299..41b622f 100644 --- a/.eslintrc.base.json +++ b/.eslintrc.base.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/eslintrc", "rules": { "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], - "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "curly": "error", diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 618c1bc..146b5f7 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -153,10 +153,11 @@ jobs: OPERATOR_LAUNCH_TEST_ENABLED: 'true' run: cargo test --test launch_integration -- --test-threads=1 --nocapture - - name: Cleanup tmux sessions + - name: Cleanup sessions if: always() run: | tmux list-sessions -F '#{session_name}' 2>/dev/null | grep '^op' | xargs -I {} tmux kill-session -t {} || true + zellij delete-all-sessions --yes --force 2>/dev/null || true build: needs: lint-test diff --git a/.github/workflows/integration-tests-matrix.yml b/.github/workflows/integration-tests-matrix.yml index 2ad868d..4e44960 100644 --- a/.github/workflows/integration-tests-matrix.yml +++ b/.github/workflows/integration-tests-matrix.yml @@ -8,6 +8,7 @@ on: - 'opr8r/**' - 'vscode-extension/**' - 'tests/**' + - 'scripts/ci/**' - '.github/workflows/integration-tests-matrix.yml' pull_request: branches: [main] @@ -16,6 +17,7 @@ on: - 'opr8r/**' - 'vscode-extension/**' - 'tests/**' + - 'scripts/ci/**' workflow_dispatch: inputs: run_vscode_tests: @@ -26,6 +28,18 @@ on: description: 'Run tmux-based launch tests' type: boolean default: true + run_zellij_tests: + description: 'Run zellij-based launch tests' + type: boolean + default: true + run_cmux_tests: + description: 'Run cmux-based launch tests (mock, macOS)' + type: boolean + default: true + run_vscode_wrapper_tests: + description: 'Run VS Code wrapper launch tests (Rust)' + type: boolean + default: true run_api_tests: description: 'Run REST API tests' type: boolean @@ -197,6 +211,176 @@ jobs: if: always() run: tmux kill-server 2>/dev/null || true + # ============================================================================ + # ZELLIJ INTEGRATION TESTS (Linux only) + # ============================================================================ + + test-zellij-launch: + name: Zellij Launch (${{ matrix.os }}) + needs: [build-operator, build-opr8r] + if: github.event_name != 'workflow_dispatch' || inputs.run_zellij_tests + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + operator_artifact: operator-linux-x64 + opr8r_artifact: opr8r-linux-x64 + - os: ubuntu-24.04-arm + operator_artifact: operator-linux-arm64 + opr8r_artifact: opr8r-linux-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@1.85.0 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-test- + + - name: Download operator binary + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.operator_artifact }} + path: target/release + + - name: Download opr8r binary + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.opr8r_artifact }} + path: target/release + + - name: Make binaries executable + run: chmod +x target/release/operator target/release/opr8r + + - name: Install zellij + run: | + ZELLIJ_VERSION="0.41.2" + if [ "$(uname -m)" = "aarch64" ]; then + ARCH="aarch64" + else + ARCH="x86_64" + fi + curl -L "https://github.com/zellij-org/zellij/releases/download/v${ZELLIJ_VERSION}/zellij-${ARCH}-unknown-linux-musl.tar.gz" | tar xz + sudo mv zellij /usr/local/bin/ + zellij --version + + - name: Run zellij launch integration tests + run: scripts/ci/run-zellij-integration.sh + + - name: Cleanup zellij + if: always() + run: | + zellij delete-all-sessions --yes --force 2>/dev/null || true + pkill -f zellij 2>/dev/null || true + + # ============================================================================ + # CMUX INTEGRATION TESTS (mock-based, macOS) + # ============================================================================ + + test-cmux-launch: + name: cmux Launch (mock, ${{ matrix.os }}) + needs: [build-operator, build-opr8r] + if: github.event_name != 'workflow_dispatch' || inputs.run_cmux_tests + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + operator_artifact: operator-macos-arm64 + opr8r_artifact: opr8r-macos-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@1.85.0 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-test- + + - name: Download operator binary + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.operator_artifact }} + path: target/release + + - name: Download opr8r binary + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.opr8r_artifact }} + path: target/release + + - name: Make binaries executable + run: chmod +x target/release/operator target/release/opr8r + + - name: Run cmux integration tests (mock-based) + env: + OPERATOR_CMUX_TEST_ENABLED: 'true' + run: cargo test --test launch_integration_cmux -- --nocapture --test-threads=1 + + # ============================================================================ + # VSCODE WRAPPER LAUNCH TESTS (Rust library-level, all platforms) + # ============================================================================ + + test-vscode-wrapper: + name: VSCode Wrapper (${{ matrix.os }}) + needs: build-operator + if: github.event_name != 'workflow_dispatch' || inputs.run_vscode_wrapper_tests + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact: operator-linux-x64 + - os: macos-14 + artifact: operator-macos-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@1.85.0 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-test- + + - name: Download operator binary + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: target/release + + - name: Make binary executable + run: chmod +x target/release/operator + + - name: Run VS Code wrapper launch tests + env: + OPERATOR_VSCODE_TEST_ENABLED: 'true' + run: cargo test --test launch_integration_vscode -- --nocapture --test-threads=1 + # ============================================================================ # REST API INTEGRATION TESTS (All platforms) # ============================================================================ diff --git a/Cargo.lock b/Cargo.lock index 09391b5..6662a6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -94,6 +100,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -584,6 +599,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -703,6 +727,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -910,6 +945,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -990,6 +1035,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -1010,6 +1066,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1681,12 +1738,32 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1937,6 +2014,7 @@ dependencies = [ "config", "crossterm", "dirs", + "futures-util", "glob", "handlebars", "http-body-util", @@ -1957,6 +2035,7 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", + "tokio-stream", "toml", "tower", "tower-http", @@ -1966,6 +2045,7 @@ dependencies = [ "ts-rs", "tui-textarea", "utoipa", + "utoipa-swagger-ui", "uuid", "which", ] @@ -2470,6 +2550,40 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust-ini" version = "0.20.0" @@ -2800,6 +2914,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.11" @@ -3130,6 +3250,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -3401,6 +3532,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -3497,6 +3634,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "utoipa-swagger-ui" +version = "8.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db4b5ac679cc6dfc5ea3f2823b0291c777750ffd5e13b21137e0f7ac0e8f9617" +dependencies = [ + "axum", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.19.0" @@ -4345,6 +4500,35 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.17", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/Cargo.toml b/Cargo.toml index 1644e24..9013250 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,16 +78,85 @@ async-trait = "0.1" # REST API axum = { version = "0.7", features = ["macros", "tokio", "tracing"] } +tokio-stream = "0.1" +futures-util = "0.3" tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace"] } http-body-util = "0.1" # OpenAPI documentation utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } +utoipa-swagger-ui = { version = "8", features = ["axum"] } [dev-dependencies] tempfile = "3" +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } + +# Nursery lints for cohesion +cognitive_complexity = "warn" +redundant_clone = "warn" + +# Allow noisy pedantic lints that don't add value here +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +return_self_not_must_use = "allow" +struct_excessive_bools = "allow" +too_many_lines = "allow" +cast_possible_truncation = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +wildcard_imports = "allow" +# TUI/builder patterns make these too noisy +unused_self = "allow" +trivially_copy_pass_by_ref = "allow" +needless_pass_by_value = "allow" +similar_names = "allow" +struct_field_names = "allow" +# String building in TUI renderers +format_push_string = "allow" +# Debug formatting is intentional for error messages +unnecessary_debug_formatting = "allow" +# Many functions wrap for future error paths +unnecessary_wraps = "allow" +# Common in async code stubs +unused_async = "allow" +# Doc links with quotes are fine +doc_link_with_quotes = "allow" +# Allow cast_possible_wrap for u64→i64 in timestamps +cast_possible_wrap = "allow" +# Match arms kept separate for readability in TUI event handlers +match_same_arms = "allow" +# clone_from is less readable than reassignment +assigning_clones = "allow" +# let-else not always clearer +manual_let_else = "allow" +# Items after statements common in this codebase +items_after_statements = "allow" +# &Option is idiomatic in this codebase for optional refs +ref_option = "allow" +# Excessive bools in setup functions are intentional +fn_params_excessive_bools = "allow" +# HashMap without explicit hasher is fine for non-perf-critical code +implicit_hasher = "allow" +# lazy_static migration tracked separately +non_std_lazy_statics = "allow" +# map_or readability is subjective +map_unwrap_or = "allow" +# for_each vs for loop is stylistic +needless_for_each = "allow" +# Explicit continue aids readability in complex loops +needless_continue = "allow" +# Wildcard matches are intentional for future-proofing +match_wildcard_for_single_variants = "allow" + # Platform-specific notifications [target.'cfg(target_os = "macos")'.dependencies] mac-notification-sys = "0.6" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 76300e6..d524f49 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -16,6 +16,7 @@ pool: vmImage: windows-latest steps: + - checkout: none # This disables the automatic source code checkout - task: PowerShell@2 displayName: Download unsigned binary from blob inputs: diff --git a/bindings/ActiveAgentResponse.ts b/bindings/ActiveAgentResponse.ts index 32e72fe..8a0de77 100644 --- a/bindings/ActiveAgentResponse.ts +++ b/bindings/ActiveAgentResponse.ts @@ -21,7 +21,7 @@ ticket_type: string, */ project: string, /** - * Agent status: running, awaiting_input, completing + * Agent status: running, `awaiting_input`, completing */ status: string, /** diff --git a/bindings/AgentState.ts b/bindings/AgentState.ts index 6454f0f..f704eb3 100644 --- a/bindings/AgentState.ts +++ b/bindings/AgentState.ts @@ -2,9 +2,25 @@ export type AgentState = { id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, /** - * The tmux session name for this agent (for recovery) + * The terminal session name for this agent (for recovery) */ session_name: string | null, +/** + * Which session wrapper manages this agent: "tmux", "vscode", or "cmux" (None = legacy tmux) + */ +session_wrapper: string | null, +/** + * Session window reference ID (top-level grouping: cmux window, tmux session, etc.) + */ +session_window_ref: string | null, +/** + * Session context reference ID (mid-level: cmux workspace, tmux window, etc.) + */ +session_context_ref: string | null, +/** + * Session pane reference ID (leaf-level: cmux surface, tmux pane, etc.) + */ +session_pane_ref: string | null, /** * Hash of the last captured pane content (for change detection) */ @@ -34,7 +50,7 @@ pr_number: bigint | null, */ github_repo: string | null, /** - * Last known PR status ("open", "approved", "changes_requested", "merged", "closed") + * Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") */ pr_status: string | null, /** @@ -45,13 +61,17 @@ completed_steps: Array, * LLM tool used (e.g., "claude", "gemini", "codex") */ llm_tool: string | null, +/** + * LLM model alias (e.g., "opus", "sonnet", "gpt-4o") + */ +llm_model: string | null, /** * Launch mode: "default", "yolo", "docker", "docker-yolo" */ launch_mode: string | null, /** - * Review state for awaiting_input agents - * Values: "pending_plan", "pending_visual", "pending_pr_creation", "pending_pr_merge" + * Review state for `awaiting_input` agents + * Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" */ review_state: string | null, /** diff --git a/bindings/BackstageConfig.ts b/bindings/BackstageConfig.ts index 4cf7bc9..77aa8ae 100644 --- a/bindings/BackstageConfig.ts +++ b/bindings/BackstageConfig.ts @@ -18,7 +18,7 @@ port: number, */ auto_start: boolean, /** - * Subdirectory within state_path for Backstage installation + * Subdirectory within `state_path` for Backstage installation */ subpath: string, /** @@ -31,7 +31,7 @@ branding_subpath: string, release_url: string, /** * Optional local path to backstage-server binary - * If set, this is used instead of downloading from release_url + * If set, this is used instead of downloading from `release_url` */ local_binary_path: string | null, /** diff --git a/bindings/CmuxPlacementPolicy.ts b/bindings/CmuxPlacementPolicy.ts new file mode 100644 index 0000000..c5f337f --- /dev/null +++ b/bindings/CmuxPlacementPolicy.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Placement policy for cmux sessions: where to create new agent terminals + */ +export type CmuxPlacementPolicy = "auto" | "workspace" | "window"; diff --git a/bindings/Config.ts b/bindings/Config.ts index 1a9b472..fc10970 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -24,7 +24,7 @@ export type Config = { */ projects: Array, agents: AgentsConfig, notifications: NotificationsConfig, queue: QueueConfig, paths: PathsConfig, ui: UiConfig, launch: LaunchConfig, templates: TemplatesConfig, api: ApiConfig, logging: LoggingConfig, tmux: TmuxConfig, /** - * Session wrapper configuration (tmux or vscode) + * Session wrapper configuration (tmux, vscode, or cmux) */ sessions: SessionsConfig, llm_tools: LlmToolsConfig, backstage: BackstageConfig, rest_api: RestApiConfig, git: GitConfig, /** diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts index 5c75950..bdbe2c3 100644 --- a/bindings/Delegator.ts +++ b/bindings/Delegator.ts @@ -25,7 +25,7 @@ model: string, */ display_name: string | null, /** - * Arbitrary model properties (e.g., reasoning_effort, sandbox) + * Arbitrary model properties (e.g., `reasoning_effort`, sandbox) */ model_properties: { [key in string]?: string }, /** diff --git a/bindings/DetectedTool.ts b/bindings/DetectedTool.ts index add5ddd..1e12f93 100644 --- a/bindings/DetectedTool.ts +++ b/bindings/DetectedTool.ts @@ -30,7 +30,7 @@ version_ok: boolean, */ model_aliases: Array, /** - * Command template with {{model}}, {{session_id}}, {{prompt_file}} placeholders + * Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders */ command_template: string, /** diff --git a/bindings/ExecutionProcess.ts b/bindings/ExecutionProcess.ts index 040c6af..f945f91 100644 --- a/bindings/ExecutionProcess.ts +++ b/bindings/ExecutionProcess.ts @@ -7,7 +7,7 @@ import type { RunReason } from "./RunReason"; * An individual execution process within an attempt * * Each attempt can spawn multiple processes: setup script, coding agent, cleanup. - * This maps to vibe-kanban's ExecutionProcess concept. + * This maps to vibe-kanban's `ExecutionProcess` concept. */ export type ExecutionProcess = { /** diff --git a/bindings/ExternalIssueTypeSummary.ts b/bindings/ExternalIssueTypeSummary.ts new file mode 100644 index 0000000..21dbfaf --- /dev/null +++ b/bindings/ExternalIssueTypeSummary.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Summary of an issue type from an external kanban provider (Jira, Linear) + */ +export type ExternalIssueTypeSummary = { +/** + * Provider-specific unique identifier + */ +id: string, +/** + * Issue type name (e.g., "Bug", "Story", "Task") + */ +name: string, +/** + * Description of the issue type + */ +description: string | null, +/** + * Icon/avatar URL from the provider + */ +icon_url: string | null, }; diff --git a/bindings/GitHubConfig.ts b/bindings/GitHubConfig.ts index 5a68fd0..c084690 100644 --- a/bindings/GitHubConfig.ts +++ b/bindings/GitHubConfig.ts @@ -9,6 +9,6 @@ export type GitHubConfig = { */ enabled: boolean, /** - * Environment variable containing the GitHub token (default: GITHUB_TOKEN) + * Environment variable containing the GitHub token (default: `GITHUB_TOKEN`) */ token_env: string, }; diff --git a/bindings/GitLabConfig.ts b/bindings/GitLabConfig.ts index 0b34689..68508df 100644 --- a/bindings/GitLabConfig.ts +++ b/bindings/GitLabConfig.ts @@ -9,7 +9,7 @@ export type GitLabConfig = { */ enabled: boolean, /** - * Environment variable containing the GitLab token (default: GITLAB_TOKEN) + * Environment variable containing the GitLab token (default: `GITLAB_TOKEN`) */ token_env: string, /** diff --git a/bindings/JiraConfig.ts b/bindings/JiraConfig.ts index cdccbc2..0194d0e 100644 --- a/bindings/JiraConfig.ts +++ b/bindings/JiraConfig.ts @@ -4,7 +4,7 @@ import type { ProjectSyncConfig } from "./ProjectSyncConfig"; /** * Jira Cloud provider configuration * - * The domain is specified as the HashMap key in KanbanConfig.jira + * The domain is specified as the `HashMap` key in KanbanConfig.jira */ export type JiraConfig = { /** @@ -12,7 +12,7 @@ export type JiraConfig = { */ enabled: boolean, /** - * Environment variable name containing the API key (default: OPERATOR_JIRA_API_KEY) + * Environment variable name containing the API key (default: `OPERATOR_JIRA_API_KEY`) */ api_key_env: string, /** diff --git a/bindings/LaunchTicketRequest.ts b/bindings/LaunchTicketRequest.ts index 4f48262..3e24017 100644 --- a/bindings/LaunchTicketRequest.ts +++ b/bindings/LaunchTicketRequest.ts @@ -17,7 +17,7 @@ model: string | null, */ yolo_mode: boolean, /** - * Session wrapper type: "vscode", "tmux", "terminal" + * Session wrapper type: "vscode", "tmux", "cmux", "terminal" */ wrapper: string | null, /** diff --git a/bindings/LaunchTicketResponse.ts b/bindings/LaunchTicketResponse.ts index 7e336c3..d59cbd4 100644 --- a/bindings/LaunchTicketResponse.ts +++ b/bindings/LaunchTicketResponse.ts @@ -21,13 +21,21 @@ working_directory: string, */ command: string, /** - * Terminal name to use (same value as tmux_session_name) + * Terminal name to use (same value as `tmux_session_name`) */ terminal_name: string, /** - * Tmux session name for attaching (same value as terminal_name) + * Tmux session name for attaching (same value as `terminal_name`, kept for backward compat) */ tmux_session_name: string, +/** + * Which session wrapper was used: "tmux", "vscode", or "cmux" + */ +session_wrapper: string | null, +/** + * Session window reference ID (e.g. cmux window, tmux session) + */ +session_window_ref: string | null, /** * Session UUID for the LLM tool */ diff --git a/bindings/LinearConfig.ts b/bindings/LinearConfig.ts index 0d0b711..c9e37b0 100644 --- a/bindings/LinearConfig.ts +++ b/bindings/LinearConfig.ts @@ -4,7 +4,7 @@ import type { ProjectSyncConfig } from "./ProjectSyncConfig"; /** * Linear provider configuration * - * The workspace slug is specified as the HashMap key in KanbanConfig.linear + * The workspace slug is specified as the `HashMap` key in KanbanConfig.linear */ export type LinearConfig = { /** @@ -12,7 +12,7 @@ export type LinearConfig = { */ enabled: boolean, /** - * Environment variable name containing the API key (default: OPERATOR_LINEAR_API_KEY) + * Environment variable name containing the API key (default: `OPERATOR_LINEAR_API_KEY`) */ api_key_env: string, /** diff --git a/bindings/LlmToolsConfig.ts b/bindings/LlmToolsConfig.ts index 6c375aa..c79a97a 100644 --- a/bindings/LlmToolsConfig.ts +++ b/bindings/LlmToolsConfig.ts @@ -21,6 +21,6 @@ providers: Array, */ detection_complete: boolean, /** - * Per-tool overrides for skill directories (keyed by tool_name) + * Per-tool overrides for skill directories (keyed by `tool_name`) */ skill_directory_overrides: { [key in string]?: SkillDirectoriesOverride }, }; diff --git a/bindings/McpDescriptorResponse.ts b/bindings/McpDescriptorResponse.ts new file mode 100644 index 0000000..0e94b17 --- /dev/null +++ b/bindings/McpDescriptorResponse.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * MCP server descriptor for client discovery + */ +export type McpDescriptorResponse = { +/** + * Server name used in MCP registration (e.g. "operator") + */ +server_name: string, +/** + * Unique server identifier (e.g. "operator-mcp") + */ +server_id: string, +/** + * Server version from Cargo.toml + */ +version: string, +/** + * Full URL of the MCP SSE transport endpoint + */ +transport_url: string, +/** + * Human-readable label for the server + */ +label: string, +/** + * URL of the OpenAPI spec for reference + */ +openapi_url: string | null, }; diff --git a/bindings/OperatorOutput.ts b/bindings/OperatorOutput.ts index 1bf562c..bd78250 100644 --- a/bindings/OperatorOutput.ts +++ b/bindings/OperatorOutput.ts @@ -8,7 +8,7 @@ */ export type OperatorOutput = { /** - * Current work status: in_progress, complete, blocked, failed + * Current work status: `in_progress`, complete, blocked, failed */ status: string, /** @@ -24,7 +24,7 @@ confidence: number | null, */ files_modified: number | null, /** - * Test suite status: passing, failing, skipped, not_run + * Test suite status: passing, failing, skipped, `not_run` */ tests_status: string | null, /** diff --git a/bindings/OsNotificationConfig.ts b/bindings/OsNotificationConfig.ts index 7052020..4e1d5f0 100644 --- a/bindings/OsNotificationConfig.ts +++ b/bindings/OsNotificationConfig.ts @@ -15,8 +15,8 @@ sound: boolean, /** * Events to send (empty = all events) * Possible values: agent.started, agent.completed, agent.failed, - * agent.awaiting_input, agent.session_lost, pr.created, pr.merged, - * pr.closed, pr.ready_to_merge, pr.changes_requested, + * `agent.awaiting_input`, `agent.session_lost`, pr.created, pr.merged, + * pr.closed, `pr.ready_to_merge`, `pr.changes_requested`, * ticket.returned, investigation.created */ events: Array, }; diff --git a/bindings/ProjectSummary.ts b/bindings/ProjectSummary.ts index 55867b8..1162bdc 100644 --- a/bindings/ProjectSummary.ts +++ b/bindings/ProjectSummary.ts @@ -25,7 +25,7 @@ has_catalog_info: boolean, */ has_project_context: boolean, /** - * Primary Kind from kind_assessment (e.g., "microservice") + * Primary Kind from `kind_assessment` (e.g., "microservice") */ kind: string | null, /** diff --git a/bindings/ProjectSyncConfig.ts b/bindings/ProjectSyncConfig.ts index 4f2127b..e132668 100644 --- a/bindings/ProjectSyncConfig.ts +++ b/bindings/ProjectSyncConfig.ts @@ -15,6 +15,11 @@ sync_user_id: string, */ sync_statuses: Array, /** - * IssueTypeCollection name this project maps to + * `IssueTypeCollection` name this project maps to */ -collection_name: string, }; +collection_name: string, +/** + * Optional explicit mapping overrides: external issue type name → operator issue type key + * When empty, convention-based auto-matching is used (Bug→FIX, Story→FEAT, etc.) + */ +type_mappings: { [key in string]?: string }, }; diff --git a/bindings/Session.ts b/bindings/Session.ts index 34c26fc..a431c2a 100644 --- a/bindings/Session.ts +++ b/bindings/Session.ts @@ -16,9 +16,13 @@ id: string, */ attempt_id: string, /** - * Tmux session name for terminal-based execution + * Terminal session name for terminal-based execution */ -tmux_session_name: string | null, +terminal_session_name: string | null, +/** + * Which session wrapper manages this session: "tmux", "vscode", or "cmux" + */ +session_wrapper: string | null, /** * Hash of terminal content for change detection */ diff --git a/bindings/SessionWrapperType.ts b/bindings/SessionWrapperType.ts index 42856aa..ae120ee 100644 --- a/bindings/SessionWrapperType.ts +++ b/bindings/SessionWrapperType.ts @@ -3,4 +3,4 @@ /** * Session wrapper type for terminal session management */ -export type SessionWrapperType = "tmux" | "vscode"; +export type SessionWrapperType = "tmux" | "vscode" | "cmux" | "zellij"; diff --git a/bindings/SessionsCmuxConfig.ts b/bindings/SessionsCmuxConfig.ts new file mode 100644 index 0000000..b30cb36 --- /dev/null +++ b/bindings/SessionsCmuxConfig.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CmuxPlacementPolicy } from "./CmuxPlacementPolicy"; + +/** + * cmux macOS terminal multiplexer session configuration + */ +export type SessionsCmuxConfig = { +/** + * Path to the cmux binary + */ +binary_path: string, +/** + * Require running inside cmux (`CMUX_WORKSPACE_ID` env var present) + */ +require_in_cmux: boolean, +/** + * Where to place new agent sessions: "auto", "workspace", or "window" + */ +placement: CmuxPlacementPolicy, }; diff --git a/bindings/SessionsConfig.ts b/bindings/SessionsConfig.ts index 98cd090..19642e8 100644 --- a/bindings/SessionsConfig.ts +++ b/bindings/SessionsConfig.ts @@ -1,15 +1,19 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SessionWrapperType } from "./SessionWrapperType"; +import type { SessionsCmuxConfig } from "./SessionsCmuxConfig"; import type { SessionsTmuxConfig } from "./SessionsTmuxConfig"; import type { SessionsVSCodeConfig } from "./SessionsVSCodeConfig"; +import type { SessionsZellijConfig } from "./SessionsZellijConfig"; /** * Session wrapper configuration * * Controls how operator creates and manages terminal sessions for agents. - * Two modes are supported: + * Four modes are supported: * - tmux: Standalone tmux sessions (default) * - vscode: VS Code integrated terminal (requires extension) + * - cmux: macOS terminal multiplexer (requires running inside cmux) + * - zellij: Zellij terminal workspace manager */ export type SessionsConfig = { /** @@ -23,4 +27,12 @@ tmux: SessionsTmuxConfig, /** * VS Code-specific configuration */ -vscode: SessionsVSCodeConfig, }; +vscode: SessionsVSCodeConfig, +/** + * cmux-specific configuration + */ +cmux: SessionsCmuxConfig, +/** + * Zellij-specific configuration + */ +zellij: SessionsZellijConfig, }; diff --git a/bindings/SessionsZellijConfig.ts b/bindings/SessionsZellijConfig.ts new file mode 100644 index 0000000..3ce383d --- /dev/null +++ b/bindings/SessionsZellijConfig.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Zellij terminal workspace manager session configuration + */ +export type SessionsZellijConfig = { +/** + * Require running inside Zellij (ZELLIJ env var present) + */ +require_in_zellij: boolean, }; diff --git a/bindings/State.ts b/bindings/State.ts index f473727..09e35dd 100644 --- a/bindings/State.ts +++ b/bindings/State.ts @@ -9,6 +9,6 @@ export type State = { paused: boolean, agents: Array, completed: Arr */ project_llm_stats: { [key in string]?: ProjectLlmStats }, /** - * Per-project issue type collection preferences (project_name -> collection_name) + * Per-project issue type collection preferences (`project_name` -> `collection_name`) */ project_collection_prefs: { [key in string]?: string }, }; diff --git a/bindings/StepAttempt.ts b/bindings/StepAttempt.ts index 4803aa0..0e1a9e6 100644 --- a/bindings/StepAttempt.ts +++ b/bindings/StepAttempt.ts @@ -66,9 +66,13 @@ status: AttemptStatus, */ paired: boolean, /** - * Tmux session name (for Operator's terminal-based execution) + * Terminal session name (for Operator's terminal-based execution) */ -tmux_session: string | null, +terminal_session: string | null, +/** + * Which session wrapper manages this attempt: "tmux", "vscode", or "cmux" + */ +session_wrapper: string | null, /** * Hash of last captured terminal content (for change detection) */ @@ -86,7 +90,7 @@ pr_number: bigint | null, */ github_repo: string | null, /** - * Current PR status ("open", "approved", "changes_requested", "merged", "closed") + * Current PR status ("open", "approved", "`changes_requested`", "merged", "closed") */ pr_status: string | null, /** diff --git a/bindings/StepCompleteRequest.ts b/bindings/StepCompleteRequest.ts index 9bfbb61..1919dd6 100644 --- a/bindings/StepCompleteRequest.ts +++ b/bindings/StepCompleteRequest.ts @@ -14,7 +14,7 @@ exit_code: number, */ output_valid: boolean, /** - * List of validation errors (if output_valid is false) + * List of validation errors (if `output_valid` is false) */ output_schema_errors: Array | null, /** @@ -30,6 +30,6 @@ duration_secs: bigint, */ output_sample: string | null, /** - * Structured output from agent (parsed OPERATOR_STATUS block) + * Structured output from agent (parsed `OPERATOR_STATUS` block) */ output: OperatorOutput | null, }; diff --git a/bindings/StepCompleteResponse.ts b/bindings/StepCompleteResponse.ts index 1865da2..3480ff5 100644 --- a/bindings/StepCompleteResponse.ts +++ b/bindings/StepCompleteResponse.ts @@ -6,7 +6,7 @@ import type { NextStepInfo } from "./NextStepInfo"; */ export type StepCompleteResponse = { /** - * Status of the step: "completed", "awaiting_review", "failed", "iterate" + * Status of the step: "completed", "`awaiting_review`", "failed", "iterate" */ status: string, /** @@ -22,11 +22,11 @@ auto_proceed: boolean, */ next_command: string | null, /** - * Whether OperatorOutput was successfully parsed from agent output + * Whether `OperatorOutput` was successfully parsed from agent output */ output_valid: boolean, /** - * Agent has more work (exit_signal=false) - indicates iteration needed + * Agent has more work (`exit_signal=false`) - indicates iteration needed */ should_iterate: boolean, /** @@ -34,15 +34,15 @@ should_iterate: boolean, */ iteration_count: number, /** - * Circuit breaker state: closed (normal), half_open (monitoring), open (halted) + * Circuit breaker state: closed (normal), `half_open` (monitoring), open (halted) */ circuit_state: string, /** - * Summary from previous step's OperatorOutput + * Summary from previous step's `OperatorOutput` */ previous_summary: string | null, /** - * Recommendation from previous step's OperatorOutput + * Recommendation from previous step's `OperatorOutput` */ previous_recommendation: string | null, /** diff --git a/bindings/TemplatesConfig.ts b/bindings/TemplatesConfig.ts index 025a978..54e75ab 100644 --- a/bindings/TemplatesConfig.ts +++ b/bindings/TemplatesConfig.ts @@ -4,7 +4,7 @@ import type { CollectionPreset } from "./CollectionPreset"; export type TemplatesConfig = { /** * Named preset for issue type collection - * Options: simple, dev_kanban, devops_kanban, custom + * Options: simple, `dev_kanban`, `devops_kanban`, custom */ preset: CollectionPreset, /** diff --git a/bindings/VsCodeLaunchOptions.ts b/bindings/VsCodeLaunchOptions.ts index bfc873b..ef011df 100644 --- a/bindings/VsCodeLaunchOptions.ts +++ b/bindings/VsCodeLaunchOptions.ts @@ -14,6 +14,6 @@ model: VsCodeModelOption, */ yoloMode: boolean, /** - * Resume from existing session (uses session_id from ticket) + * Resume from existing session (uses `session_id` from ticket) */ resumeSession: boolean, }; diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..d0d0c5a --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +cognitive-complexity-threshold = 80 +too-many-arguments-threshold = 8 +type-complexity-threshold = 300 +allowed-idents-below-min-chars = ["x", "y", "r", "f", "e", "i", "n", "s", "k", "v"] +doc-valid-idents = ["GitHub", "GitLab", "macOS", "OpenAPI", "OAuth", "TypeScript", "WebSocket", "VsCode", "DevOps", "SubPath", "TodoApp","TOML", "JSON", "YAML", "UUID", "URL", "API", "CLI", "TUI", "PR", "SSH", "HTTP", "HTTPS", "stdin", "stdout", "tmux", "stderr"] diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index a7655d5..96d3bf4 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -62,6 +62,9 @@ docs: url: /tickets/ - title: Agents url: /agents/ + children: + - title: Artifact Detection + url: /agents/artifact-detection/ - title: Reference children: - title: CLI diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html new file mode 100644 index 0000000..00b9b7a --- /dev/null +++ b/docs/_includes/footer.html @@ -0,0 +1,7 @@ + diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 902dc3e..d2b4c1b 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -8,6 +8,8 @@ {{ content }} + {% include footer.html %} + + `; } @@ -138,10 +138,10 @@ export class ConfigPanel { openLabel: 'Select File', }); if (fileUri && fileUri.length > 0) { - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'browseResult', field: message.field, - path: fileUri[0].fsPath, + path: fileUri[0]!.fsPath, }); } break; @@ -155,15 +155,15 @@ export class ConfigPanel { openLabel: 'Select Folder', }); if (folderUri && folderUri.length > 0) { - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'browseResult', field: message.field, - path: folderUri[0].fsPath, + path: folderUri[0]!.fsPath, }); // Also persist to VS Code settings await vscode.workspace .getConfiguration('operator') - .update('workingDirectory', folderUri[0].fsPath, vscode.ConfigurationTarget.Global); + .update('workingDirectory', folderUri[0]!.fsPath, vscode.ConfigurationTarget.Global); } break; } @@ -184,7 +184,7 @@ export class ConfigPanel { ); } - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'jiraValidationResult', result: { valid: result.valid, @@ -202,7 +202,7 @@ export class ConfigPanel { message.apiKey as string ); - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'linearValidationResult', result: { valid: result.valid, @@ -229,13 +229,13 @@ export class ConfigPanel { } try { const config = await readConfig(); - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'llmToolsDetected', config, }); } catch { // If we can't read config, just send tool names for compatibility - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'llmToolsDetected', config: { config_path: configPath || '', @@ -254,9 +254,9 @@ export class ConfigPanel { const apiUrl = await discoverApiUrl(ticketsDir); const client = new OperatorApiClient(apiUrl); await client.health(); - this._panel.webview.postMessage({ type: 'apiHealthResult', reachable: true }); + void this._panel.webview.postMessage({ type: 'apiHealthResult', reachable: true }); } catch { - this._panel.webview.postMessage({ type: 'apiHealthResult', reachable: false }); + void this._panel.webview.postMessage({ type: 'apiHealthResult', reachable: false }); } break; } @@ -268,9 +268,9 @@ export class ConfigPanel { const apiUrl = await discoverApiUrl(ticketsDir); const client = new OperatorApiClient(apiUrl); const projects = await client.getProjects(); - this._panel.webview.postMessage({ type: 'projectsLoaded', projects }); + void this._panel.webview.postMessage({ type: 'projectsLoaded', projects }); } catch (err) { - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'projectsError', error: err instanceof Error ? err.message : 'Failed to load projects', }); @@ -286,13 +286,13 @@ export class ConfigPanel { const apiUrl = await discoverApiUrl(ticketsDir); const client = new OperatorApiClient(apiUrl); const result = await client.assessProject(projectName); - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'assessTicketCreated', ticketId: result.ticket_id, projectName: result.project_name, }); } catch (err) { - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'assessTicketError', error: err instanceof Error ? err.message : 'Failed to create ASSESS ticket', projectName, @@ -311,7 +311,7 @@ export class ConfigPanel { } case 'openExternal': - vscode.env.openExternal(vscode.Uri.parse(message.url as string)); + void vscode.env.openExternal(vscode.Uri.parse(message.url as string)); break; case 'openFile': { @@ -322,6 +322,157 @@ export class ConfigPanel { } break; } + + case 'getIssueTypes': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const issueTypes = await client.listIssueTypes(); + void this._panel.webview.postMessage({ type: 'issueTypesLoaded', issueTypes }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'issueTypeError', + error: err instanceof Error ? err.message : 'Failed to load issue types', + }); + } + break; + } + + case 'getIssueType': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const issueType = await client.getIssueType(message.key as string); + void this._panel.webview.postMessage({ type: 'issueTypeLoaded', issueType }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'issueTypeError', + error: err instanceof Error ? err.message : 'Failed to load issue type', + }); + } + break; + } + + case 'getCollections': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const collections = await client.listCollections(); + void this._panel.webview.postMessage({ type: 'collectionsLoaded', collections }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'collectionsError', + error: err instanceof Error ? err.message : 'Failed to load collections', + }); + } + break; + } + + case 'activateCollection': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + await client.activateCollection(message.name as string); + void this._panel.webview.postMessage({ type: 'collectionActivated', name: message.name as string }); + // Refresh collections after activation + const collections = await client.listCollections(); + void this._panel.webview.postMessage({ type: 'collectionsLoaded', collections }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'collectionsError', + error: err instanceof Error ? err.message : 'Failed to activate collection', + }); + } + break; + } + + case 'getExternalIssueTypes': { + const provider = message.provider as string; + const projectKey = message.projectKey as string; + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const types = await client.getExternalIssueTypes(provider, projectKey); + void this._panel.webview.postMessage({ + type: 'externalIssueTypesLoaded', + provider, + projectKey, + types, + }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'externalIssueTypesError', + provider, + projectKey, + error: err instanceof Error ? err.message : 'Failed to load external issue types', + }); + } + break; + } + + case 'createIssueType': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const issueType = await client.createIssueType(message.request as Parameters[0]); + void this._panel.webview.postMessage({ type: 'issueTypeCreated', issueType }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'issueTypeError', + error: err instanceof Error ? err.message : 'Failed to create issue type', + }); + } + break; + } + + case 'updateIssueType': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const issueType = await client.updateIssueType( + message.key as string, + message.request as Parameters[1] + ); + void this._panel.webview.postMessage({ type: 'issueTypeUpdated', issueType }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'issueTypeError', + error: err instanceof Error ? err.message : 'Failed to update issue type', + }); + } + break; + } + + case 'deleteIssueType': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + await client.deleteIssueType(message.key as string); + void this._panel.webview.postMessage({ type: 'issueTypeDeleted', key: message.key as string }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'issueTypeError', + error: err instanceof Error ? err.message : 'Failed to delete issue type', + }); + } + break; + } } } @@ -329,12 +480,12 @@ export class ConfigPanel { private async _sendConfig(): Promise { try { const config = await readConfig(); - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'configLoaded', config, }); } catch (err) { - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'configError', error: err instanceof Error ? err.message : 'Failed to load config', }); @@ -351,12 +502,12 @@ export class ConfigPanel { await writeConfigField(section, key, value); const config = await readConfig(); - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'configUpdated', config, }); } catch (err) { - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'configError', error: err instanceof Error ? err.message : 'Failed to update config', }); @@ -485,14 +636,14 @@ async function writeConfigField( if (!ws.projects) { ws.projects = {}; } const projects = ws.projects as TomlConfig; const oldKeys = Object.keys(projects); - if (oldKeys.length > 0) { + if (oldKeys.length > 0 && oldKeys[0]) { const oldProject = projects[oldKeys[0]]; delete projects[oldKeys[0]]; projects[value as string] = oldProject; } else { projects[value as string] = { sync_user_id: '', collection_name: 'dev_kanban' }; } - } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id') { + } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { // Write to the first project sub-table if (!ws.projects) { ws.projects = {}; } const projects = ws.projects as TomlConfig; @@ -500,6 +651,17 @@ async function writeConfigField( const projectKey = projectKeys[0] ?? 'default'; if (!projects[projectKey]) { projects[projectKey] = {}; } (projects[projectKey] as TomlConfig)[key] = value; + } else if (key.startsWith('projects.')) { + // Multi-project writes: kanban.jira + projects.{projectKey}.{field} + const parts = key.split('.'); + if (parts.length >= 3 && parts[1]) { + const pKey = parts[1]; + const field = parts.slice(2).join('.'); + if (!ws.projects) { ws.projects = {}; } + const projects = ws.projects as TomlConfig; + if (!projects[pKey]) { projects[pKey] = { sync_user_id: '', collection_name: 'dev_kanban' }; } + (projects[pKey] as TomlConfig)[field] = value; + } } else { ws[key] = value; } @@ -522,7 +684,7 @@ async function writeConfigField( const existing = linear[teamId]; delete linear[teamId]; linear[value] = existing; - } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id') { + } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { // Write to the first project sub-table if (!ws.projects) { ws.projects = {}; } const projects = ws.projects as TomlConfig; @@ -530,6 +692,17 @@ async function writeConfigField( const projectKey = projectKeys[0] ?? 'default'; if (!projects[projectKey]) { projects[projectKey] = {}; } (projects[projectKey] as TomlConfig)[key] = value; + } else if (key.startsWith('projects.')) { + // Multi-project writes: kanban.linear + projects.{projectKey}.{field} + const parts = key.split('.'); + if (parts.length >= 3 && parts[1]) { + const pKey = parts[1]; + const field = parts.slice(2).join('.'); + if (!ws.projects) { ws.projects = {}; } + const projects = ws.projects as TomlConfig; + if (!projects[pKey]) { projects[pKey] = { sync_user_id: '', collection_name: '' }; } + (projects[pKey] as TomlConfig)[field] = value; + } } else { ws[key] = value; } diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 2c1efc5..1c2b2f1 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -12,6 +12,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs/promises'; +import * as os from 'os'; import { TerminalManager } from './terminal-manager'; import { WebhookServer } from './webhook-server'; import { TicketTreeProvider, TicketItem } from './ticket-provider'; @@ -38,10 +39,12 @@ import { detectLlmTools, openWalkthrough, startKanbanOnboarding, + initializeTicketsDirectory, } from './walkthrough'; import { addJiraProject, addLinearTeam } from './kanban-onboarding'; import { ConfigPanel } from './config-panel'; -import { configFileExists, getResolvedConfigPath } from './config-paths'; +import { configFileExists } from './config-paths'; +import { connectMcpServer } from './mcp-connect'; /** * Show a notification when config.toml is missing, with a button to open the walkthrough. @@ -49,7 +52,7 @@ import { configFileExists, getResolvedConfigPath } from './config-paths'; function showConfigMissingNotification(): void { // Fire notification without awaiting to prevent blocking activation void vscode.window.showInformationMessage( - `Operator! could not find configuration ${getResolvedConfigPath() || 'config.toml'}. Run the setup walkthrough to create it and get started`, + 'Could not find Operator! configuration file for this repository workspace. Run the setup walkthrough to create it and get started.', 'Open Setup' ).then((choice) => { if (choice === 'Open Setup') { @@ -179,6 +182,49 @@ export async function activate( await selectWorkingDirectory(extensionContext, operatorPath ?? undefined); } ), + vscode.commands.registerCommand( + 'operator.runSetup', + async () => { + const workingDir = extensionContext.globalState.get('operator.workingDirectory'); + if (!workingDir) { + await vscode.commands.executeCommand('operator.selectWorkingDirectory'); + return; + } + + const choice = await vscode.window.showInformationMessage( + `Run operator setup in ${workingDir.replace(os.homedir(), '~')}?`, + 'Yes', + 'Cancel' + ); + + if (choice !== 'Yes') { + return; + } + + const operatorPath = await getOperatorPath(extensionContext); + const success = await initializeTicketsDirectory(workingDir, operatorPath ?? undefined); + + if (success) { + // Use the known working dir directly — findParentTicketsDir searches + // relative to workspace folder and may not find the newly created dir + currentTicketsDir = path.join(workingDir, '.tickets'); + await setTicketsDir(currentTicketsDir); + + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(currentTicketsDir, '**/*.md') + ); + watcher.onDidChange(() => refreshAllProviders()); + watcher.onDidCreate(() => refreshAllProviders()); + watcher.onDidDelete(() => refreshAllProviders()); + extensionContext.subscriptions.push(watcher); + + await updateOperatorContext(); + void vscode.window.showInformationMessage('Operator setup completed successfully.'); + } else { + void vscode.window.showErrorMessage('Failed to run operator setup.'); + } + } + ), vscode.commands.registerCommand( 'operator.checkKanbanConnection', () => checkKanbanConnection(extensionContext) @@ -218,6 +264,14 @@ export async function activate( vscode.commands.registerCommand( 'operator.revealTicketsDir', revealTicketsDirCommand + ), + vscode.commands.registerCommand( + 'operator.startWebhookServer', + startServer + ), + vscode.commands.registerCommand( + 'operator.connectMcpServer', + () => connectMcpServer(currentTicketsDir) ) ); @@ -263,7 +317,7 @@ export async function activate( // Auto-open walkthrough for new users with no working directory const workingDirectory = context.globalState.get('operator.workingDirectory'); if (!workingDirectory) { - vscode.commands.executeCommand( + void vscode.commands.executeCommand( 'workbench.action.openWalkthrough', 'untra.operator-terminals#operator-setup', false @@ -304,13 +358,7 @@ async function findParentTicketsDir(): Promise { await fs.access(ticketsPath); return ticketsPath; } catch { - // .tickets directory doesn't exist yet - create it - try { - await fs.mkdir(ticketsPath, { recursive: true }); - return ticketsPath; - } catch { - return undefined; - } + return undefined; } } @@ -328,10 +376,10 @@ async function findTicketsDir(): Promise { .getConfiguration('operator') .get('ticketsDir', '.tickets'); - // If absolute path configured, use it directly + // If absolute path configured, check if it exists if (path.isAbsolute(configuredDir)) { try { - await fs.mkdir(configuredDir, { recursive: true }); + await fs.access(configuredDir); return configuredDir; } catch { return undefined; @@ -353,20 +401,8 @@ async function findTicketsDir(): Promise { } } - // Not found - create in parent of workspace (org level) - const parentDir = path.dirname(workspaceFolder.uri.fsPath); - if (parentDir === workspaceFolder.uri.fsPath) { - // Workspace is at filesystem root - return undefined; - } - - const ticketsPath = path.join(parentDir, configuredDir); - try { - await fs.mkdir(ticketsPath, { recursive: true }); - return ticketsPath; - } catch { - return undefined; - } + // Not found anywhere + return undefined; } /** @@ -424,11 +460,11 @@ async function focusTicketTerminal( ticket?: TicketInfo ): Promise { if (terminalManager.exists(terminalName)) { - await terminalManager.focus(terminalName); + terminalManager.focus(terminalName); } else if (ticket) { await launchManager.offerRelaunch(ticket); } else { - vscode.window.showWarningMessage(`Terminal '${terminalName}' not found`); + void vscode.window.showWarningMessage(`Terminal '${terminalName}' not found`); } } @@ -436,8 +472,8 @@ async function focusTicketTerminal( * Open a ticket file */ function openTicketFile(filePath: string): void { - vscode.workspace.openTextDocument(filePath).then((doc) => { - vscode.window.showTextDocument(doc); + void vscode.workspace.openTextDocument(filePath).then((doc) => { + void vscode.window.showTextDocument(doc); }); } @@ -453,7 +489,7 @@ async function startServer(): Promise { } if (webhookServer.isRunning()) { - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( 'Operator webhook server already running' ); return; @@ -470,11 +506,11 @@ async function startServer(): Promise { const configuredPort = webhookServer.getConfiguredPort(); if (port !== configuredPort) { - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Operator webhook server started on port ${port} (configured port ${configuredPort} was in use)` ); } else { - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Operator webhook server started on port ${port}` ); } @@ -482,7 +518,7 @@ async function startServer(): Promise { updateStatusBar(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to start webhook server: ${msg}`); + void vscode.window.showErrorMessage(`Failed to start webhook server: ${msg}`); } } @@ -506,7 +542,7 @@ function showStatus(): void { message = 'Operator server stopped'; } - vscode.window.showInformationMessage(message); + void vscode.window.showInformationMessage(message); } /** @@ -544,7 +580,7 @@ async function launchTicketCommand(treeItem?: TicketItem): Promise { // Called from command palette - show picker const tickets = queueProvider.getTickets(); if (tickets.length === 0) { - vscode.window.showInformationMessage('No tickets in queue'); + void vscode.window.showInformationMessage('No tickets in queue'); return; } ticket = await showTicketPicker(tickets); @@ -579,7 +615,7 @@ async function launchTicketWithOptionsCommand( // Called from command palette - show picker const tickets = queueProvider.getTickets(); if (tickets.length === 0) { - vscode.window.showInformationMessage('No tickets in queue'); + void vscode.window.showInformationMessage('No tickets in queue'); return; } ticket = await showTicketPicker(tickets); @@ -627,13 +663,13 @@ function isTicketFile(filePath: string): boolean { async function launchTicketFromEditorCommand(): Promise { const editor = vscode.window.activeTextEditor; if (!editor) { - vscode.window.showWarningMessage('No active editor'); + void vscode.window.showWarningMessage('No active editor'); return; } const filePath = editor.document.uri.fsPath; if (!isTicketFile(filePath)) { - vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( 'Current file is not a ticket in .tickets/ directory' ); return; @@ -641,7 +677,7 @@ async function launchTicketFromEditorCommand(): Promise { const metadata = await parseTicketMetadata(filePath); if (!metadata?.id) { - vscode.window.showErrorMessage('Could not parse ticket ID from file'); + void vscode.window.showErrorMessage('Could not parse ticket ID from file'); return; } @@ -651,7 +687,7 @@ async function launchTicketFromEditorCommand(): Promise { try { await apiClient.health(); } catch { - vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( 'Operator API not running. Start operator first.' ); return; @@ -669,15 +705,15 @@ async function launchTicketFromEditorCommand(): Promise { }); // Create terminal and execute command - await terminalManager.create({ + terminalManager.create({ name: response.terminal_name, workingDir: response.working_directory, }); - await terminalManager.send(response.terminal_name, response.command); - await terminalManager.focus(response.terminal_name); + terminalManager.send(response.terminal_name, response.command); + terminalManager.focus(response.terminal_name); const worktreeMsg = response.worktree_created ? ' (worktree created)' : ''; - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Launched agent for ${response.ticket_id}${worktreeMsg}` ); @@ -685,7 +721,7 @@ async function launchTicketFromEditorCommand(): Promise { await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to launch: ${msg}`); + void vscode.window.showErrorMessage(`Failed to launch: ${msg}`); } } @@ -695,13 +731,13 @@ async function launchTicketFromEditorCommand(): Promise { async function launchTicketFromEditorWithOptionsCommand(): Promise { const editor = vscode.window.activeTextEditor; if (!editor) { - vscode.window.showWarningMessage('No active editor'); + void vscode.window.showWarningMessage('No active editor'); return; } const filePath = editor.document.uri.fsPath; if (!isTicketFile(filePath)) { - vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( 'Current file is not a ticket in .tickets/ directory' ); return; @@ -709,7 +745,7 @@ async function launchTicketFromEditorWithOptionsCommand(): Promise { const metadata = await parseTicketMetadata(filePath); if (!metadata?.id) { - vscode.window.showErrorMessage('Could not parse ticket ID from file'); + void vscode.window.showErrorMessage('Could not parse ticket ID from file'); return; } @@ -738,7 +774,7 @@ async function launchTicketFromEditorWithOptionsCommand(): Promise { try { await apiClient.health(); } catch { - vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( 'Operator API not running. Start operator first.' ); return; @@ -756,15 +792,15 @@ async function launchTicketFromEditorWithOptionsCommand(): Promise { }); // Create terminal and execute command - await terminalManager.create({ + terminalManager.create({ name: response.terminal_name, workingDir: response.working_directory, }); - await terminalManager.send(response.terminal_name, response.command); - await terminalManager.focus(response.terminal_name); + terminalManager.send(response.terminal_name, response.command); + terminalManager.focus(response.terminal_name); const worktreeMsg = response.worktree_created ? ' (worktree created)' : ''; - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Launched agent for ${response.ticket_id}${worktreeMsg}` ); @@ -772,7 +808,7 @@ async function launchTicketFromEditorWithOptionsCommand(): Promise { await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to launch: ${msg}`); + void vscode.window.showErrorMessage(`Failed to launch: ${msg}`); } } @@ -815,7 +851,7 @@ async function downloadOperatorCommand(): Promise { ); if (choice === 'Open Downloads Page') { - vscode.env.openExternal( + void vscode.env.openExternal( vscode.Uri.parse('https://operator.untra.io/downloads/') ); return; @@ -828,7 +864,7 @@ async function downloadOperatorCommand(): Promise { const downloadedPath = await downloadOperator(extensionContext); const version = await getOperatorVersion(downloadedPath); - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Operator ${version ?? getExtensionVersion()} downloaded successfully to ${downloadedPath}` ); @@ -848,7 +884,7 @@ async function downloadOperatorCommand(): Promise { ); if (choice === 'Open Downloads Page') { - vscode.env.openExternal( + void vscode.env.openExternal( vscode.Uri.parse('https://operator.untra.io/downloads/') ); } @@ -884,7 +920,7 @@ async function startOperatorServerCommand(): Promise { // Find the directory to run the operator server in const serverDir = await findOperatorServerDir(); if (!serverDir) { - vscode.window.showErrorMessage('No workspace folder found.'); + void vscode.window.showErrorMessage('No workspace folder found.'); return; } @@ -892,7 +928,7 @@ async function startOperatorServerCommand(): Promise { const apiClient = new OperatorApiClient(); try { await apiClient.health(); - vscode.window.showInformationMessage('Operator is already running'); + void vscode.window.showInformationMessage('Operator is already running'); return; } catch { // Not running, proceed to start @@ -902,25 +938,25 @@ async function startOperatorServerCommand(): Promise { const terminalName = 'Operator API'; if (terminalManager.exists(terminalName)) { - await terminalManager.focus(terminalName); + terminalManager.focus(terminalName); return; } - await terminalManager.create({ + terminalManager.create({ name: terminalName, workingDir: serverDir, }); - await terminalManager.send(terminalName, `"${operatorPath}" api`); - await terminalManager.focus(terminalName); + terminalManager.send(terminalName, `"${operatorPath}" api`); + terminalManager.focus(terminalName); - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Starting Operator API server in ${serverDir}...` ); // Wait a moment and refresh providers to pick up the new status - setTimeout(async () => { - await refreshAllProviders(); + setTimeout(() => { + void refreshAllProviders(); }, 2000); } @@ -933,7 +969,7 @@ async function pauseQueueCommand(): Promise { try { await apiClient.health(); } catch { - vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( 'Operator API not running. Start operator first.' ); return; @@ -941,11 +977,11 @@ async function pauseQueueCommand(): Promise { try { const result = await apiClient.pauseQueue(); - vscode.window.showInformationMessage(result.message); + void vscode.window.showInformationMessage(result.message); await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to pause queue: ${msg}`); + void vscode.window.showErrorMessage(`Failed to pause queue: ${msg}`); } } @@ -958,7 +994,7 @@ async function resumeQueueCommand(): Promise { try { await apiClient.health(); } catch { - vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( 'Operator API not running. Start operator first.' ); return; @@ -966,11 +1002,11 @@ async function resumeQueueCommand(): Promise { try { const result = await apiClient.resumeQueue(); - vscode.window.showInformationMessage(result.message); + void vscode.window.showInformationMessage(result.message); await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to resume queue: ${msg}`); + void vscode.window.showErrorMessage(`Failed to resume queue: ${msg}`); } } @@ -983,7 +1019,7 @@ async function syncKanbanCommand(): Promise { try { await apiClient.health(); } catch { - vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( 'Operator API not running. Start operator first.' ); return; @@ -993,16 +1029,16 @@ async function syncKanbanCommand(): Promise { const result = await apiClient.syncKanban(); const message = `Synced: ${result.created.length} created, ${result.skipped.length} skipped`; if (result.errors.length > 0) { - vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `${message}, ${result.errors.length} errors` ); } else { - vscode.window.showInformationMessage(message); + void vscode.window.showInformationMessage(message); } await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to sync kanban: ${msg}`); + void vscode.window.showErrorMessage(`Failed to sync kanban: ${msg}`); } } @@ -1015,7 +1051,7 @@ async function approveReviewCommand(agentId: string): Promise { try { await apiClient.health(); } catch { - vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( 'Operator API not running. Start operator first.' ); return; @@ -1031,11 +1067,11 @@ async function approveReviewCommand(agentId: string): Promise { try { const result = await apiClient.approveReview(selectedAgentId); - vscode.window.showInformationMessage(result.message); + void vscode.window.showInformationMessage(result.message); await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to approve review: ${msg}`); + void vscode.window.showErrorMessage(`Failed to approve review: ${msg}`); } } @@ -1048,7 +1084,7 @@ async function rejectReviewCommand(agentId: string): Promise { try { await apiClient.health(); } catch { - vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( 'Operator API not running. Start operator first.' ); return; @@ -1080,11 +1116,11 @@ async function rejectReviewCommand(agentId: string): Promise { try { const result = await apiClient.rejectReview(selectedAgentId, reason); - vscode.window.showInformationMessage(result.message); + void vscode.window.showInformationMessage(result.message); await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to reject review: ${msg}`); + void vscode.window.showErrorMessage(`Failed to reject review: ${msg}`); } } @@ -1100,7 +1136,7 @@ async function showAwaitingAgentPicker( `${vscode.workspace.getConfiguration('operator').get('apiUrl', 'http://localhost:7008')}/api/v1/agents/active` ); if (!response.ok) { - vscode.window.showErrorMessage('Failed to fetch active agents'); + void vscode.window.showErrorMessage('Failed to fetch active agents'); return undefined; } const data = (await response.json()) as { @@ -1117,7 +1153,7 @@ async function showAwaitingAgentPicker( ); if (awaitingAgents.length === 0) { - vscode.window.showInformationMessage('No agents awaiting review'); + void vscode.window.showInformationMessage('No agents awaiting review'); return undefined; } @@ -1134,7 +1170,7 @@ async function showAwaitingAgentPicker( return selected?.agentId; } catch (err) { - vscode.window.showErrorMessage('Failed to fetch agents'); + void vscode.window.showErrorMessage('Failed to fetch agents'); return undefined; } } @@ -1147,7 +1183,7 @@ async function syncKanbanCollectionCommand(item: StatusItem): Promise { const projectKey = item.projectKey; if (!provider || !projectKey) { - vscode.window.showWarningMessage('No collection selected for sync.'); + void vscode.window.showWarningMessage('No collection selected for sync.'); return; } @@ -1156,7 +1192,7 @@ async function syncKanbanCollectionCommand(item: StatusItem): Promise { try { await apiClient.health(); } catch { - vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( 'Operator API not running. Start operator first.' ); return; @@ -1169,14 +1205,14 @@ async function syncKanbanCollectionCommand(item: StatusItem): Promise { : ''; const message = `Synced ${projectKey}: ${result.created.length} created${createdList}, ${result.skipped.length} skipped`; if (result.errors.length > 0) { - vscode.window.showWarningMessage(`${message}, ${result.errors.length} errors`); + void vscode.window.showWarningMessage(`${message}, ${result.errors.length} errors`); } else { - vscode.window.showInformationMessage(message); + void vscode.window.showInformationMessage(message); } await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to sync collection: ${msg}`); + void vscode.window.showErrorMessage(`Failed to sync collection: ${msg}`); } } @@ -1201,7 +1237,7 @@ async function addLinearTeamCommand(workspaceKey: string): Promise { */ async function revealTicketsDirCommand(): Promise { if (!currentTicketsDir) { - vscode.window.showWarningMessage('No .tickets directory found.'); + void vscode.window.showWarningMessage('No .tickets directory found.'); return; } @@ -1213,6 +1249,6 @@ async function revealTicketsDirCommand(): Promise { * Extension deactivation */ export function deactivate(): void { - webhookServer?.stop(); + void webhookServer?.stop(); terminalManager?.dispose(); } diff --git a/vscode-extension/src/issuetype-service.ts b/vscode-extension/src/issuetype-service.ts index bede7f2..40b573b 100644 --- a/vscode-extension/src/issuetype-service.ts +++ b/vscode-extension/src/issuetype-service.ts @@ -239,7 +239,7 @@ export class IssueTypeService { */ extractTypeFromId(ticketId: string): string { const parts = ticketId.split('-'); - if (parts.length >= 2) { + if (parts.length >= 2 && parts[0]) { const prefix = parts[0].toUpperCase(); // Validate it looks like a type key (uppercase letters only) if (/^[A-Z]+$/.test(prefix)) { @@ -261,7 +261,7 @@ export class IssueTypeService { const baseName = filename.replace(/\.md$/, ''); const match = baseName.match(/^([A-Z]+)-(\d+)/i); - if (match) { + if (match?.[1] && match[2]) { const type = match[1].toUpperCase(); const id = `${type}-${match[2]}`; return { id, type }; @@ -276,7 +276,7 @@ export class IssueTypeService { getIconForTerminal(name: string): vscode.ThemeIcon { // Terminal names are like "op-FEAT-123" const typeMatch = name.match(/op-([A-Z]+)-/i); - if (typeMatch) { + if (typeMatch?.[1]) { return this.getIcon(typeMatch[1]); } return new vscode.ThemeIcon('terminal'); @@ -288,7 +288,7 @@ export class IssueTypeService { getColorForTerminal(name: string): vscode.ThemeColor { // Terminal names are like "op-FEAT-123" const typeMatch = name.match(/op-([A-Z]+)-/i); - if (typeMatch) { + if (typeMatch?.[1]) { return this.getColor(typeMatch[1]) ?? new vscode.ThemeColor('terminal.ansiWhite'); } return new vscode.ThemeColor('terminal.ansiWhite'); diff --git a/vscode-extension/src/kanban-onboarding.ts b/vscode-extension/src/kanban-onboarding.ts index 8284281..2ef489e 100644 --- a/vscode-extension/src/kanban-onboarding.ts +++ b/vscode-extension/src/kanban-onboarding.ts @@ -88,7 +88,7 @@ export async function writeKanbanConfig(section: string): Promise { } // Extract the section header (first line) to check for duplicates - const headerLine = section.split('\n')[0]; + const headerLine = section.split('\n')[0]!; if (existing.includes(headerLine)) { const replace = await vscode.window.showWarningMessage( `Config already contains ${headerLine}. Replace it?`, @@ -310,7 +310,7 @@ export function showInputBoxWithBack(options: { password?: boolean; validate?: (value: string) => string | undefined; buttons?: vscode.QuickInputButton[]; -}): Promise { +}): Promise { return new Promise((resolve) => { const input = vscode.window.createInputBox(); input.title = options.title; @@ -386,7 +386,7 @@ export async function showEnvVarInstructions(envLines: string[]): Promise if (action === 'Copy to Clipboard') { await vscode.env.clipboard.writeText(exportBlock); - vscode.window.showInformationMessage('Environment variable exports copied to clipboard.'); + void vscode.window.showInformationMessage('Environment variable exports copied to clipboard.'); } } @@ -469,7 +469,7 @@ export async function onboardJira( input.ignoreFocusOut = true; input.buttons = [vscode.QuickInputButtons.Back, openTokenPage]; - const result = await new Promise((resolve) => { + const result = await new Promise((resolve) => { let resolved = false; input.onDidAccept(() => { @@ -489,7 +489,7 @@ export async function onboardJira( input.dispose(); resolve('back'); } else if (button === openTokenPage) { - vscode.env.openExternal( + void vscode.env.openExternal( vscode.Uri.parse('https://id.atlassian.com/manage-profile/security/api-tokens') ); } @@ -534,7 +534,7 @@ export async function onboardJira( return; } - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Authenticated as ${validation.displayName} (${validation.accountId})` ); @@ -549,7 +549,7 @@ export async function onboardJira( ); if (projects.length === 0) { - vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( 'No projects found. Check your permissions. Config was not written.' ); return; @@ -591,7 +591,7 @@ export async function onboardJira( process.env['OPERATOR_JIRA_EMAIL'] = email; // Show success + env var instructions - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Jira configured! Config written to ${getResolvedConfigPath()}` ); @@ -655,7 +655,7 @@ export async function onboardLinear( input.onDidTriggerButton((button) => { if (button === openLinearSettings) { - vscode.env.openExternal( + void vscode.env.openExternal( vscode.Uri.parse('https://linear.app/settings/api') ); } @@ -697,13 +697,13 @@ export async function onboardLinear( return; } - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Authenticated as ${validation.userName} in ${validation.orgName}` ); // Step 2: Select team if (validation.teams.length === 0) { - vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( 'No teams found. Check your permissions. Config was not written.' ); return; @@ -730,7 +730,7 @@ export async function onboardLinear( // Write config const envVarName = 'OPERATOR_LINEAR_API_KEY'; const toml = generateLinearToml( - selectedTeam.detail!, + selectedTeam.detail ?? '', envVarName, validation.userId ); @@ -744,7 +744,7 @@ export async function onboardLinear( process.env['OPERATOR_LINEAR_API_KEY'] = apiKey; // Show success + env var instructions - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Linear configured! Config written to ${getResolvedConfigPath()}` ); @@ -765,12 +765,12 @@ export async function startKanbanOnboarding( const choice = await vscode.window.showQuickPick( [ { - label: '$(cloud) Jira Cloud', + label: '$(operator-atlassian) Jira Cloud', description: 'Connect to Jira Cloud with API token', provider: 'jira' as const, }, { - label: '$(cloud) Linear', + label: '$(operator-linear) Linear', description: 'Connect to Linear with API key', provider: 'linear' as const, }, @@ -861,7 +861,7 @@ export async function addJiraProject( domain?: string ): Promise { if (!domain) { - vscode.window.showErrorMessage('No Jira domain specified.'); + void vscode.window.showErrorMessage('No Jira domain specified.'); return; } @@ -871,17 +871,30 @@ export async function addJiraProject( const jiraSection = kanban?.jira as Record | undefined; const wsConfig = jiraSection?.[domain] as Record | undefined; - if (!wsConfig) { - vscode.window.showErrorMessage(`No Jira workspace configured for ${domain}.`); - return; - } + let email: string | undefined; + let apiKeyEnv: string; + let apiToken: string | undefined; + const fromEnvVars = !wsConfig; - const email = wsConfig.email as string | undefined; - const apiKeyEnv = (wsConfig.api_key_env as string) || 'OPERATOR_JIRA_API_KEY'; - let apiToken = process.env[apiKeyEnv]; + if (wsConfig) { + email = wsConfig.email as string | undefined; + apiKeyEnv = (wsConfig.api_key_env as string) || 'OPERATOR_JIRA_API_KEY'; + apiToken = process.env[apiKeyEnv]; + } else { + // Fall back to env-var detection + const envEmail = process.env['OPERATOR_JIRA_EMAIL']; + const envApiKey = process.env['OPERATOR_JIRA_API_KEY']; + if (!envEmail || !envApiKey) { + void vscode.window.showErrorMessage(`No Jira workspace configured for ${domain}.`); + return; + } + email = envEmail; + apiToken = envApiKey; + apiKeyEnv = 'OPERATOR_JIRA_API_KEY'; + } if (!email) { - vscode.window.showErrorMessage(`No email configured for Jira workspace ${domain}.`); + void vscode.window.showErrorMessage(`No email configured for Jira workspace ${domain}.`); return; } @@ -900,7 +913,7 @@ export async function addJiraProject( // Find already-configured project keys const existingProjects = new Set(); - const projectsSection = wsConfig.projects as Record | undefined; + const projectsSection = wsConfig?.projects as Record | undefined; if (projectsSection) { for (const key of Object.keys(projectsSection)) { existingProjects.add(key); @@ -914,18 +927,18 @@ export async function addJiraProject( title: 'Fetching Jira projects...', cancellable: false, }, - () => fetchJiraProjects(domain, email, apiToken!) + () => fetchJiraProjects(domain, email, apiToken) ); if (projects.length === 0) { - vscode.window.showWarningMessage('No projects found. Check your permissions.'); + void vscode.window.showWarningMessage('No projects found. Check your permissions.'); return; } // Filter out already-configured projects const available = projects.filter((p) => !existingProjects.has(p.key)); if (available.length === 0) { - vscode.window.showInformationMessage('All available projects are already configured.'); + void vscode.window.showInformationMessage('All available projects are already configured.'); return; } @@ -943,16 +956,19 @@ export async function addJiraProject( // Get the user's account ID from validation const validation = await validateJiraCredentials(domain, email, apiToken); if (!validation.valid) { - vscode.window.showErrorMessage(`Jira validation failed: ${validation.error}`); + void vscode.window.showErrorMessage(`Jira validation failed: ${validation.error}`); return; } // Write project section to config.toml - const toml = generateJiraProjectToml(domain, selected.label, validation.accountId, 'dev_kanban'); + // When from env vars, write the full workspace section to promote into TOML + const toml = fromEnvVars + ? generateJiraToml(domain, email, apiKeyEnv, selected.label, validation.accountId) + : generateJiraProjectToml(domain, selected.label, validation.accountId, 'dev_kanban'); const written = await writeKanbanConfig(toml); if (!written) { return; } - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Added Jira project ${selected.label} to ${domain}` ); @@ -970,7 +986,7 @@ export async function addLinearTeam( workspaceKey?: string ): Promise { if (!workspaceKey) { - vscode.window.showErrorMessage('No Linear workspace specified.'); + void vscode.window.showErrorMessage('No Linear workspace specified.'); return; } @@ -980,13 +996,23 @@ export async function addLinearTeam( const linearSection = kanban?.linear as Record | undefined; const wsConfig = linearSection?.[workspaceKey] as Record | undefined; - if (!wsConfig) { - vscode.window.showErrorMessage(`No Linear workspace configured for ${workspaceKey}.`); - return; - } + let apiKeyEnv: string; + let apiKey: string | undefined; + const fromEnvVars = !wsConfig; - const apiKeyEnv = (wsConfig.api_key_env as string) || 'OPERATOR_LINEAR_API_KEY'; - let apiKey = process.env[apiKeyEnv]; + if (wsConfig) { + apiKeyEnv = (wsConfig.api_key_env as string) || 'OPERATOR_LINEAR_API_KEY'; + apiKey = process.env[apiKeyEnv]; + } else { + // Fall back to env-var detection + const envApiKey = process.env['OPERATOR_LINEAR_API_KEY']; + if (!envApiKey) { + void vscode.window.showErrorMessage(`No Linear workspace configured for ${workspaceKey}.`); + return; + } + apiKey = envApiKey; + apiKeyEnv = 'OPERATOR_LINEAR_API_KEY'; + } // Prompt for API key if not in env if (!apiKey) { @@ -1003,7 +1029,7 @@ export async function addLinearTeam( // Find already-configured team keys const existingTeams = new Set(); - const projectsSection = wsConfig.projects as Record | undefined; + const projectsSection = wsConfig?.projects as Record | undefined; if (projectsSection) { for (const key of Object.keys(projectsSection)) { existingTeams.add(key); @@ -1017,30 +1043,30 @@ export async function addLinearTeam( title: 'Fetching Linear teams...', cancellable: false, }, - () => validateLinearCredentials(apiKey!) + () => validateLinearCredentials(apiKey) ); if (!validation.valid) { - vscode.window.showErrorMessage(`Linear validation failed: ${validation.error}`); + void vscode.window.showErrorMessage(`Linear validation failed: ${validation.error}`); return; } if (validation.teams.length === 0) { - vscode.window.showWarningMessage('No teams found. Check your permissions.'); + void vscode.window.showWarningMessage('No teams found. Check your permissions.'); return; } // Filter out already-configured teams const available = validation.teams.filter((t) => !existingTeams.has(t.key)); if (available.length === 0) { - vscode.window.showInformationMessage('All available teams are already configured.'); + void vscode.window.showInformationMessage('All available teams are already configured.'); return; } const selected = await vscode.window.showQuickPick( available.map((t) => ({ label: t.key, description: t.name, detail: t.id })), { - title: 'Add Linear Team', + title: 'Add Linear Workspace', placeHolder: 'Select a team to sync', ignoreFocusOut: true, } @@ -1049,11 +1075,14 @@ export async function addLinearTeam( if (!selected) { return; } // Write team section to config.toml - const toml = generateLinearTeamToml(workspaceKey, selected.label, validation.userId, 'dev_kanban'); + // When from env vars, write the full workspace section to promote into TOML + const toml = fromEnvVars + ? generateLinearToml(workspaceKey, apiKeyEnv, validation.userId) + : generateLinearTeamToml(workspaceKey, selected.label, validation.userId, 'dev_kanban'); const written = await writeKanbanConfig(toml); if (!written) { return; } - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Added Linear team ${selected.label} (${selected.description})` ); diff --git a/vscode-extension/src/launch-dialog.ts b/vscode-extension/src/launch-dialog.ts index 11d81f0..5a11663 100644 --- a/vscode-extension/src/launch-dialog.ts +++ b/vscode-extension/src/launch-dialog.ts @@ -93,7 +93,7 @@ export async function showTicketPicker( tickets: TicketInfo[] ): Promise { if (tickets.length === 0) { - vscode.window.showInformationMessage('No tickets available'); + void vscode.window.showInformationMessage('No tickets available'); return undefined; } diff --git a/vscode-extension/src/launch-manager.ts b/vscode-extension/src/launch-manager.ts index 8cb07b5..8366723 100644 --- a/vscode-extension/src/launch-manager.ts +++ b/vscode-extension/src/launch-manager.ts @@ -95,10 +95,10 @@ export class LaunchManager { ); if (choice === 'Focus Existing') { - await this.terminalManager.focus(terminalName); + this.terminalManager.focus(terminalName); return; } else if (choice === 'Kill and Relaunch') { - await this.terminalManager.kill(terminalName); + this.terminalManager.kill(terminalName); } else { return; // Cancelled } @@ -110,7 +110,7 @@ export class LaunchManager { } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; this.log(`API launch failed: ${msg}`); - vscode.window.showErrorMessage(`Failed to launch ticket: ${msg}`); + void vscode.window.showErrorMessage(`Failed to launch ticket: ${msg}`); } } @@ -151,17 +151,17 @@ export class LaunchManager { ); // Create terminal with API response - await this.terminalManager.create({ + this.terminalManager.create({ name: response.terminal_name, workingDir: response.working_directory, }); - await this.terminalManager.send(response.terminal_name, response.command); - await this.terminalManager.focus(response.terminal_name); + this.terminalManager.send(response.terminal_name, response.command); + this.terminalManager.focus(response.terminal_name); const worktreeMsg = response.worktree_created ? ' (worktree created)' : ''; const branchMsg = response.branch ? ` on branch ${response.branch}` : ''; - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Launched agent for ${ticket.id}${worktreeMsg}${branchMsg}` ); } diff --git a/vscode-extension/src/mcp-connect.ts b/vscode-extension/src/mcp-connect.ts new file mode 100644 index 0000000..41b245c --- /dev/null +++ b/vscode-extension/src/mcp-connect.ts @@ -0,0 +1,118 @@ +/** + * MCP connection logic for Operator VS Code extension. + * + * Discovers the local Operator API, fetches the MCP descriptor, + * builds a vscode:// deep link, and opens it to register the + * Operator MCP server in VS Code. + */ + +import * as vscode from 'vscode'; +import { discoverApiUrl } from './api-client'; + +/** + * MCP server descriptor returned by the Operator API. + * Matches the Rust McpDescriptorResponse DTO. + */ +export interface McpDescriptorResponse { + server_name: string; + server_id: string; + version: string; + transport_url: string; + label: string; + openapi_url: string | null; +} + +/** + * Fetch the MCP descriptor from the Operator API. + * + * @param apiUrl - Base URL of the Operator API (e.g. "http://localhost:7008") + * @returns The MCP descriptor + * @throws Error if the API is unreachable or the descriptor endpoint fails + */ +export async function fetchMcpDescriptor( + apiUrl: string +): Promise { + const url = `${apiUrl}/api/v1/mcp/descriptor`; + + let response: Response; + try { + response = await fetch(url); + } catch (err) { + throw new Error( + `Operator API is not running at ${apiUrl}. Start the server first.` + ); + } + + if (!response.ok) { + throw new Error( + `MCP descriptor unavailable (HTTP ${response.status}). ` + + 'Ensure Operator is updated to a version that supports MCP.' + ); + } + + return (await response.json()) as McpDescriptorResponse; +} + +/** + * Build a VS Code MCP deep link URI from an MCP descriptor. + * + * The deep link format is: + * vscode://modelcontextprotocol.mcp/connect?config= + * + * Where the JSON config contains: + * { name, type: "sse", url: transport_url } + */ +export function buildMcpDeepLink( + descriptor: McpDescriptorResponse +): vscode.Uri { + const config = { + name: descriptor.server_name, + type: 'sse', + url: descriptor.transport_url, + }; + + const base64 = Buffer.from(JSON.stringify(config)).toString('base64'); + return vscode.Uri.parse( + `vscode://modelcontextprotocol.mcp/connect?config=${base64}` + ); +} + +/** + * Connect Operator as an MCP server in VS Code. + * + * Discovers the running API, fetches the MCP descriptor, + * builds a deep link, and opens it. + */ +export async function connectMcpServer( + ticketsDir: string | undefined +): Promise { + try { + // 1. Discover the API URL + const apiUrl = await discoverApiUrl(ticketsDir); + + // 2. Fetch the MCP descriptor + let descriptor: McpDescriptorResponse; + try { + descriptor = await fetchMcpDescriptor(apiUrl); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to fetch MCP descriptor'; + void vscode.window.showErrorMessage(message); + return; + } + + // 3. Build and open the deep link + const uri = buildMcpDeepLink(descriptor); + + const opened = await vscode.env.openExternal(uri); + if (!opened) { + void vscode.window.showErrorMessage( + 'Failed to open MCP connection. VS Code may not support MCP deep links in this version.' + ); + } + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to connect MCP server'; + void vscode.window.showErrorMessage(message); + } +} diff --git a/vscode-extension/src/operator-binary.ts b/vscode-extension/src/operator-binary.ts index 1e8f685..bb77986 100644 --- a/vscode-extension/src/operator-binary.ts +++ b/vscode-extension/src/operator-binary.ts @@ -19,7 +19,9 @@ const GITHUB_REPO = 'untra/operator'; */ export function getExtensionVersion(): string { const extension = vscode.extensions.getExtension('untra.operator-terminals'); - return extension?.packageJSON.version || '0.2.0'; + const packageJSON = extension?.packageJSON as Record | undefined; + const version = typeof packageJSON?.version === 'string' ? packageJSON.version : '0.2.0'; + return version; } /** @@ -240,17 +242,16 @@ async function downloadWithRedirects( } }); - response.on('end', async () => { + response.on('end', () => { writeStream.end(); // Make executable on Unix if (process.platform !== 'win32') { - try { - await fs.chmod(destPath, 0o755); - } catch { - // Ignore chmod errors - } + fs.chmod(destPath, 0o755) + .catch(() => { /* Ignore chmod errors */ }) + .finally(() => { resolve(destPath); }); + } else { + resolve(destPath); } - resolve(destPath); }); response.on('error', (err) => { diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index e403dde..042cdbc 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -27,6 +27,7 @@ import { getKanbanWorkspaces, DetectedToolResult, } from './walkthrough'; +import { getOperatorPath, getOperatorVersion } from './operator-binary'; // smol-toml is ESM-only, must use dynamic import async function importSmolToml() { @@ -113,6 +114,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { private webhookStatus: WebhookStatus = { running: false }; private apiStatus: ApiStatus = { connected: false }; + private operatorVersion: string | undefined; private ticketsDir: string | undefined; private configState: ConfigState = { @@ -137,13 +139,14 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { async refresh(): Promise { this.parsedConfig = null; - await Promise.all([ + await Promise.allSettled([ this.checkConfigState(), this.checkKanbanState(), this.checkLlmState(), this.checkGitState(), this.checkWebhookStatus(), this.checkApiStatus(), + this.checkOperatorVersion(), ]); this._onDidChangeTreeData.fire(undefined); @@ -358,7 +361,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { const webhookSessionFile = path.join(this.ticketsDir, 'operator', 'vscode-session.json'); try { const content = await fs.readFile(webhookSessionFile, 'utf-8'); - const session: SessionInfo = JSON.parse(content); + const session = JSON.parse(content) as SessionInfo; this.webhookStatus = { running: true, @@ -380,7 +383,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { const apiSessionFile = path.join(this.ticketsDir, 'operator', 'api-session.json'); try { const content = await fs.readFile(apiSessionFile, 'utf-8'); - const session: ApiSessionInfo = JSON.parse(content); + const session = JSON.parse(content) as ApiSessionInfo; const apiUrl = `http://localhost:${session.port}`; if (await this.tryHealthCheck(apiUrl, session.version)) { @@ -395,6 +398,27 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { await this.tryHealthCheck(apiUrl); } + /** + * Check for locally installed operator version, or fetch latest from remote + */ + private async checkOperatorVersion(): Promise { + const operatorPath = await getOperatorPath(this.context); + if (operatorPath) { + this.operatorVersion = await getOperatorVersion(operatorPath) || undefined; + return; + } + + // No binary installed — fetch latest version from remote + try { + const response = await fetch('https://operator.untra.io/VERSION'); + if (response.ok) { + this.operatorVersion = (await response.text()).trim() || undefined; + } + } catch { + this.operatorVersion = undefined; + } + } + /** * Attempt a health check against the given API URL */ @@ -455,6 +479,19 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { private getTopLevelSections(): StatusItem[] { const configuredBoth = this.configState.workingDirSet && this.configState.configExists; + // Determine the right command when not fully configured + const configCommand = !configuredBoth + ? this.configState.workingDirSet + ? { + command: 'operator.runSetup', + title: 'Run Operator Setup', + } + : { + command: 'operator.selectWorkingDirectory', + title: 'Select Working Directory', + } + : undefined; + return [ // 1. Configuration new StatusItem({ @@ -467,18 +504,16 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded, sectionId: 'config', - command: configuredBoth ? undefined : { - command: 'operator.selectWorkingDirectory', - title: 'Select Working Directory', - }, + command: configCommand, }), // 2. Connections new StatusItem({ label: 'Connections', - description: this.getConnectionsSummary(), - icon: this.getConnectionsIcon(), + description: configuredBoth ? this.getConnectionsSummary() : 'Not Ready', + icon: configuredBoth ? this.getConnectionsIcon() : 'debug-configure', collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, sectionId: 'connections', + command: configuredBoth ? undefined : configCommand, }), // 3. Kanban Providers @@ -595,15 +630,13 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { // One collapsible item per workspace for (const prov of this.kanbanState.providers) { const providerLabel = prov.provider === 'jira' ? 'Jira' : 'Linear'; - const hasProjects = prov.projects.length > 0; + const providerIcon = prov.provider === 'jira' ? 'operator-atlassian' : 'operator-linear'; items.push(new StatusItem({ label: providerLabel, description: prov.displayName, - icon: 'cloud', + icon: providerIcon, tooltip: prov.url, - collapsibleState: hasProjects - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, command: { command: 'vscode.open', title: 'Open in Browser', @@ -628,7 +661,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { // Nudge items items.push(new StatusItem({ label: 'Configure Jira', - icon: 'cloud', + icon: 'operator-atlassian', command: { command: 'operator.configureJira', title: 'Configure Jira', @@ -636,7 +669,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { })); items.push(new StatusItem({ label: 'Configure Linear', - icon: 'cloud', + icon: 'operator-linear', command: { command: 'operator.configureLinear', title: 'Configure Linear', @@ -659,7 +692,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { items.push(new StatusItem({ label: proj.key, description: proj.collectionName, - icon: 'package', + icon: 'project', tooltip: proj.url, command: { command: 'vscode.open', @@ -674,7 +707,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { } // Add Project / Add Team button - const addLabel = provider === 'jira' ? 'Add Jira Project' : 'Add Linear Team'; + const addLabel = provider === 'jira' ? 'Add Jira Project' : 'Add Linear Workspace'; const addCommand = provider === 'jira' ? 'operator.addJiraProject' : 'operator.addLinearTeam'; items.push(new StatusItem({ label: addLabel, @@ -698,20 +731,22 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { for (const tool of this.llmState.configDetected) { shown.add(tool.name); + const icon = `operator-${tool.name}`; items.push(new StatusItem({ label: tool.name, description: tool.version, - icon: 'terminal', + icon: icon, })); } // Show PATH-detected tools not already in config for (const tool of this.llmState.tools) { if (!shown.has(tool.name)) { + const icon = `operator-${tool.name}`; items.push(new StatusItem({ label: tool.name, description: tool.version !== 'unknown' ? tool.version : undefined, - icon: 'terminal', + icon: icon, })); } } @@ -798,64 +833,100 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { } private getConnectionsChildren(): StatusItem[] { - const items: StatusItem[] = []; + const configuredBoth = this.configState.workingDirSet && this.configState.configExists; - // REST API - if (this.apiStatus.connected) { - items.push(new StatusItem({ - label: 'API', - description: this.apiStatus.url || '', - icon: 'pass', - tooltip: `Operator REST API at ${this.apiStatus.url}`, - })); - if (this.apiStatus.version) { - items.push(new StatusItem({ - label: 'API Version', - description: this.apiStatus.version, - icon: 'versions', - })); - } - if (this.apiStatus.port) { - items.push(new StatusItem({ - label: 'API Port', - description: this.apiStatus.port.toString(), - icon: 'plug', - })); - } + // 1. API Version — always shown + let versionItem: StatusItem; + if (this.apiStatus.connected && this.apiStatus.version) { + const swaggerUrl = `http://localhost:${this.apiStatus.port || 7008}/swagger-ui`; + versionItem = new StatusItem({ + label: 'Operator', + description: 'Version ' + this.apiStatus.version, + icon: 'versions', + tooltip: 'Open Swagger UI', + command: { + command: 'vscode.open', + title: 'Open Swagger UI', + arguments: [vscode.Uri.parse(swaggerUrl)], + }, + }); } else { - items.push(new StatusItem({ - label: 'API', - description: 'Disconnected', - icon: 'error', - tooltip: 'Operator REST API not running. Use "Operator: Download Operator" command if not installed.', - })); + versionItem = new StatusItem({ + label: 'Operator Version', + description: this.operatorVersion ? 'Version ' + this.operatorVersion : 'Not installed', + icon: 'versions', + tooltip: this.operatorVersion + ? `Installed: ${this.operatorVersion} — click to update` + : 'Click to download Operator', + command: { + command: 'operator.downloadOperator', + title: 'Download Operator', + }, + }); } - // Webhook - if (this.webhookStatus.running) { - items.push(new StatusItem({ - label: 'Webhook', - description: 'Running', - icon: 'pass', - tooltip: 'Local webhook server for terminal management', - })); - if (this.webhookStatus.port) { - items.push(new StatusItem({ - label: 'Webhook Port', - description: this.webhookStatus.port.toString(), + // 2. API Connection — always shown + const apiItem = this.apiStatus.connected + ? new StatusItem({ + label: 'API', + description: this.apiStatus.url || 'Connected', + icon: 'pass', + tooltip: `Operator REST API at ${this.apiStatus.url}`, + }) + : new StatusItem({ + label: 'API', + description: configuredBoth ? 'Disconnected' : 'Not Ready', + icon: 'error', + tooltip: configuredBoth + ? 'Click to start Operator API server' + : 'Complete configuration first', + command: configuredBoth ? { + command: 'operator.startOperatorServer', + title: 'Start Operator Server', + } : undefined, + }); + + // 3. Webhook Connection — always shown + const webhookItem = this.webhookStatus.running + ? new StatusItem({ + label: 'Webhook', + description: `Running${this.webhookStatus.port ? ` :${this.webhookStatus.port}` : ''}`, + icon: 'pass', + tooltip: 'Local webhook server for terminal management', + }) + : new StatusItem({ + label: 'Webhook', + description: configuredBoth ? 'Stopped' : 'Not Ready', + icon: 'circle-slash', + tooltip: configuredBoth + ? 'Click to start webhook server' + : 'Complete configuration first', + command: configuredBoth ? { + command: 'operator.startWebhookServer', + title: 'Start Webhook Server', + } : undefined, + }); + + // 4. MCP Connection + const mcpItem = this.apiStatus.connected + ? new StatusItem({ + label: 'MCP', + description: 'Connect', icon: 'plug', - })); - } - } else { - items.push(new StatusItem({ - label: 'Webhook', - description: 'Stopped', - icon: 'circle-slash', - tooltip: 'Local webhook server not running', - })); - } + tooltip: 'Connect Operator as MCP server in VS Code', + command: { + command: 'operator.connectMcpServer', + title: 'Connect MCP Server', + }, + }) + : new StatusItem({ + label: 'MCP', + description: 'API required', + icon: 'circle-slash', + tooltip: 'Start the Operator API to enable MCP connection', + }); - return items; + return [versionItem, apiItem, webhookItem, mcpItem]; } // ─── Summary Helpers ─────────────────────────────────────────────────── @@ -872,12 +943,12 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { private getLlmSummary(): string { // Prefer config-detected (has version info) if (this.llmState.configDetected.length > 0) { - const first = this.llmState.configDetected[0]; + const first = this.llmState.configDetected[0]!; return first.version ? `${first.name} v${first.version}` : first.name; } // Fall back to PATH-detected if (this.llmState.tools.length > 0) { - const first = this.llmState.tools[0]; + const first = this.llmState.tools[0]!; return first.version !== 'unknown' ? `${first.name} v${first.version}` : first.name; } return ''; diff --git a/vscode-extension/src/terminal-manager.ts b/vscode-extension/src/terminal-manager.ts index 6aa47aa..f652764 100644 --- a/vscode-extension/src/terminal-manager.ts +++ b/vscode-extension/src/terminal-manager.ts @@ -55,12 +55,12 @@ export class TerminalManager { /** * Create a new terminal with Operator styling */ - async create(options: TerminalCreateOptions): Promise { + create(options: TerminalCreateOptions): vscode.Terminal { const { name, workingDir, env } = options; // Dispose existing terminal with same name if present if (this.terminals.has(name)) { - await this.kill(name); + this.kill(name); } // Use ticket-specific colors and icons @@ -89,7 +89,7 @@ export class TerminalManager { /** * Send a command to a terminal */ - async send(name: string, command: string): Promise { + send(name: string, command: string): void { const terminal = this.terminals.get(name); if (!terminal) { throw new Error(`Terminal '${name}' not found`); @@ -100,7 +100,7 @@ export class TerminalManager { /** * Reveal a terminal without taking focus (show in panel) */ - async show(name: string): Promise { + show(name: string): void { const terminal = this.terminals.get(name); if (!terminal) { throw new Error(`Terminal '${name}' not found`); @@ -111,7 +111,7 @@ export class TerminalManager { /** * Focus a terminal (takes keyboard focus) */ - async focus(name: string): Promise { + focus(name: string): void { const terminal = this.terminals.get(name); if (!terminal) { throw new Error(`Terminal '${name}' not found`); @@ -122,7 +122,7 @@ export class TerminalManager { /** * Kill/dispose a terminal */ - async kill(name: string): Promise { + kill(name: string): void { const terminal = this.terminals.get(name); if (terminal) { terminal.dispose(); @@ -202,8 +202,8 @@ export class TerminalManager { * Dispose all resources */ dispose(): void { - this.disposables.forEach((d) => d.dispose()); - this.terminals.forEach((t) => t.dispose()); + this.disposables.forEach((d) => { d.dispose(); }); + this.terminals.forEach((t) => { t.dispose(); }); this.terminals.clear(); this.activityState.clear(); this.createdAt.clear(); diff --git a/vscode-extension/src/ticket-parser.ts b/vscode-extension/src/ticket-parser.ts index fef8e84..73dbe43 100644 --- a/vscode-extension/src/ticket-parser.ts +++ b/vscode-extension/src/ticket-parser.ts @@ -35,7 +35,7 @@ export function parseTicketContent(content: string): TicketMetadata | null { return null; } - const yaml = match[1]; + const yaml = match[1]!; const metadata: TicketMetadata = { id: '', status: '', @@ -86,11 +86,11 @@ export function parseTicketContent(content: string): TicketMetadata | null { // Parse sessions block (indented key-value pairs under 'sessions:') const sessionsMatch = yaml.match(/sessions:\s*\n((?:\s{2}\S+:.*\n?)+)/); - if (sessionsMatch) { + if (sessionsMatch?.[1]) { metadata.sessions = {}; for (const line of sessionsMatch[1].split('\n')) { const sessionMatch = line.match(/^\s+(\S+):\s*(.+)$/); - if (sessionMatch) { + if (sessionMatch?.[1] && sessionMatch[2]) { metadata.sessions[sessionMatch[1]] = sessionMatch[2].trim(); } } diff --git a/vscode-extension/src/walkthrough.ts b/vscode-extension/src/walkthrough.ts index e3d102e..77a42f4 100644 --- a/vscode-extension/src/walkthrough.ts +++ b/vscode-extension/src/walkthrough.ts @@ -95,7 +95,7 @@ const TOOL_META: Record = { export function compareVersions(version: string, minVersion: string): boolean { const parse = (v: string): number[] => { const match = v.match(/(\d+(?:\.\d+)*)/); - if (!match) { return [0]; } + if (!match?.[1]) { return [0]; } return match[1].split('.').map(Number); }; const a = parse(version); @@ -118,7 +118,7 @@ async function detectSingleTool(tool: string): Promise { const orgInfo = await fetchLinearWorkspace(apiKey); if (orgInfo) { workspaces[linearIdx] = { - ...workspaces[linearIdx], + ...workspaces[linearIdx]!, name: orgInfo.name, url: orgInfo.url, }; @@ -298,6 +298,13 @@ export async function initializeTicketsDirectory( if (operatorPath) { try { await execAsync(`"${operatorPath}" setup --working-dir "${workingDir}"`); + // Ensure config.toml exists even if CLI didn't create it + const configPath = path.join(ticketsDir, 'operator', 'config.toml'); + try { + await fs.access(configPath); + } catch { + await fs.writeFile(configPath, '', 'utf-8'); + } return true; } catch { // Fall through to manual creation @@ -317,6 +324,14 @@ export async function initializeTicketsDirectory( await fs.mkdir(dir, { recursive: true }); } + // Create empty config.toml if it doesn't exist + const configPath = path.join(ticketsDir, 'operator', 'config.toml'); + try { + await fs.access(configPath); + } catch { + await fs.writeFile(configPath, '', 'utf-8'); + } + return true; } catch (error) { console.error('Failed to initialize tickets directory:', error); @@ -392,19 +407,19 @@ export async function selectWorkingDirectory( return; } - const selectedPath = folders[0].fsPath; + const selectedPath = folders[0]!.fsPath; // Validate directory const isValid = await validateWorkingDirectory(selectedPath); if (!isValid) { - vscode.window.showErrorMessage('Selected path is not a valid directory'); + void vscode.window.showErrorMessage('Selected path is not a valid directory'); return; } // Initialize .tickets structure const initialized = await initializeTicketsDirectory(selectedPath, operatorPath); if (!initialized) { - vscode.window.showErrorMessage('Failed to initialize tickets directory structure'); + void vscode.window.showErrorMessage('Failed to initialize tickets directory structure'); return; } @@ -419,7 +434,7 @@ export async function selectWorkingDirectory( // Update context await updateWalkthroughContext(context); - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Working directory set to: ${selectedPath}` ); } @@ -445,13 +460,13 @@ export async function checkKanbanConnection( await vscode.commands.executeCommand('operator.configureLinear'); } } else if (workspaces.length === 1) { - const ws = workspaces[0]; - vscode.window.showInformationMessage( + const ws = workspaces[0]!; + void vscode.window.showInformationMessage( `Connected to ${ws.provider}: ${ws.name} (${ws.url})` ); } else { const details = workspaces.map((ws) => `${ws.provider}: ${ws.name}`).join(', '); - vscode.window.showInformationMessage( + void vscode.window.showInformationMessage( `Connected to ${workspaces.length} workspaces: ${details}` ); } @@ -483,11 +498,11 @@ export async function detectLlmTools( ); if (choice === 'Install Claude Code') { - vscode.env.openExternal(vscode.Uri.parse('https://docs.anthropic.com/en/docs/claude-code')); + void vscode.env.openExternal(vscode.Uri.parse('https://docs.anthropic.com/en/docs/claude-code')); } else if (choice === 'Install Codex') { - vscode.env.openExternal(vscode.Uri.parse('https://github.com/openai/codex')); + void vscode.env.openExternal(vscode.Uri.parse('https://github.com/openai/codex')); } else if (choice === 'Install Gemini CLI') { - vscode.env.openExternal(vscode.Uri.parse('https://github.com/google/generative-ai-docs')); + void vscode.env.openExternal(vscode.Uri.parse('https://github.com/google/generative-ai-docs')); } } else { // Build per-tool configure buttons @@ -523,7 +538,7 @@ async function configureLlmTool( : undefined; if (!operatorPath) { - vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `Operator binary not found. Download it first to configure ${tool}.` ); return; @@ -533,20 +548,36 @@ async function configureLlmTool( || context.globalState.get('operator.workingDirectory'); if (!workingDir) { - vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( 'Working directory not set. Select a working directory first.' ); return; } + // Check if operator config exists before trying to configure LLM tool + const configPath = path.join(workingDir, '.tickets', 'operator', 'config.toml'); + try { + await fs.access(configPath); + } catch { + const choice = await vscode.window.showWarningMessage( + `Operator not yet configured in ${path.basename(workingDir)}. Run setup first?`, + 'Run Setup', + 'Cancel' + ); + if (choice === 'Run Setup') { + await vscode.commands.executeCommand('operator.runSetup'); + } + return; + } + try { await execAsync( `"${operatorPath}" setup --llm-tool "${tool}" --working-dir "${workingDir}" --skip-llm-detection` ); - vscode.window.showInformationMessage(`Configured ${tool} successfully.`); + void vscode.window.showInformationMessage(`Configured ${tool} successfully.`); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to configure ${tool}: ${msg}`); + void vscode.window.showErrorMessage(`Failed to configure ${tool}: ${msg}`); } } diff --git a/vscode-extension/src/webhook-server.ts b/vscode-extension/src/webhook-server.ts index 7874eff..3868a91 100644 --- a/vscode-extension/src/webhook-server.ts +++ b/vscode-extension/src/webhook-server.ts @@ -71,9 +71,9 @@ export class WebhookServer { */ private tryListen(port: number): Promise { return new Promise((resolve, reject) => { - this.server = http.createServer((req, res) => - this.handleRequest(req, res) - ); + this.server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); this.server.on('error', reject); @@ -199,7 +199,7 @@ export class WebhookServer { // Create terminal if (urlPath === '/terminal/create' && req.method === 'POST') { const body = await this.parseBody(req); - await this.terminalManager.create(body); + this.terminalManager.create(body); const response: SuccessResponse = { success: true, name: body.name }; return this.sendJson(res, response); } @@ -212,7 +212,7 @@ export class WebhookServer { ) { const name = this.extractName(urlPath, '/terminal/', '/send'); const body = await this.parseBody(req); - await this.terminalManager.send(name, body.command); + this.terminalManager.send(name, body.command); const response: SuccessResponse = { success: true }; return this.sendJson(res, response); } @@ -224,7 +224,7 @@ export class WebhookServer { req.method === 'POST' ) { const name = this.extractName(urlPath, '/terminal/', '/show'); - await this.terminalManager.show(name); + this.terminalManager.show(name); const response: SuccessResponse = { success: true }; return this.sendJson(res, response); } @@ -236,7 +236,7 @@ export class WebhookServer { req.method === 'POST' ) { const name = this.extractName(urlPath, '/terminal/', '/focus'); - await this.terminalManager.focus(name); + this.terminalManager.focus(name); const response: SuccessResponse = { success: true }; return this.sendJson(res, response); } @@ -248,7 +248,7 @@ export class WebhookServer { req.method === 'DELETE' ) { const name = this.extractName(urlPath, '/terminal/', '/kill'); - await this.terminalManager.kill(name); + this.terminalManager.kill(name); const response: SuccessResponse = { success: true }; return this.sendJson(res, response); } @@ -313,7 +313,7 @@ export class WebhookServer { req.on('data', (chunk) => (body += chunk)); req.on('end', () => { try { - resolve(JSON.parse(body || '{}')); + resolve(JSON.parse(body || '{}') as T); } catch { reject(new Error('Invalid JSON')); } diff --git a/vscode-extension/test/fixtures/api/mcp-descriptor-response.json b/vscode-extension/test/fixtures/api/mcp-descriptor-response.json new file mode 100644 index 0000000..48dde79 --- /dev/null +++ b/vscode-extension/test/fixtures/api/mcp-descriptor-response.json @@ -0,0 +1,8 @@ +{ + "server_name": "operator", + "server_id": "operator-mcp", + "version": "0.1.26", + "transport_url": "http://localhost:7008/api/v1/mcp/sse", + "label": "Operator MCP Server", + "openapi_url": "http://localhost:7008/api-docs/openapi.json" +} diff --git a/vscode-extension/test/runTest.ts b/vscode-extension/test/runTest.ts index 014c3a2..673001a 100644 --- a/vscode-extension/test/runTest.ts +++ b/vscode-extension/test/runTest.ts @@ -20,4 +20,4 @@ async function main() { } } -main(); +void main(); diff --git a/vscode-extension/test/suite/api-client.test.ts b/vscode-extension/test/suite/api-client.test.ts index f881708..e859838 100644 --- a/vscode-extension/test/suite/api-client.test.ts +++ b/vscode-extension/test/suite/api-client.test.ts @@ -35,6 +35,26 @@ const fixturesDir = path.join( 'api' ); +/** Shape of the fetch init argument captured from sinon stubs */ +interface FetchInit { + method: string; + headers: Record; + body: string; +} + +/** Shape of the request body sent in launchTicket calls */ +interface LaunchRequestBody { + provider: string | null; + model: string | null; + yolo_mode: boolean; + wrapper: string | null; +} + +/** Shape of the request body sent in rejectReview calls */ +interface RejectRequestBody { + reason: string; +} + suite('API Client Test Suite', () => { let fetchStub: sinon.SinonStub; @@ -116,7 +136,7 @@ suite('API Client Test Suite', () => { }) ); - client.health(); + void client.health(); assert.ok( fetchStub.calledWith('http://custom:9000/api/v1/health'), @@ -133,7 +153,7 @@ suite('API Client Test Suite', () => { }) ); - client.health(); + void client.health(); // Default is http://localhost:7008 from vscode config assert.ok( @@ -147,9 +167,9 @@ suite('API Client Test Suite', () => { test('returns health response on success', async () => { const client = new OperatorApiClient('http://localhost:7008'); - const healthResponse: HealthResponse = await fs - .readFile(path.join(fixturesDir, 'health-response.json'), 'utf-8') - .then(JSON.parse); + const healthResponse: HealthResponse = JSON.parse( + await fs.readFile(path.join(fixturesDir, 'health-response.json'), 'utf-8') + ) as HealthResponse; fetchStub.resolves( new Response(JSON.stringify(healthResponse), { status: 200 }) @@ -187,9 +207,9 @@ suite('API Client Test Suite', () => { test('sends POST request with correct body', async () => { const client = new OperatorApiClient('http://localhost:7008'); - const launchResponse: LaunchTicketResponse = await fs - .readFile(path.join(fixturesDir, 'launch-response.json'), 'utf-8') - .then(JSON.parse); + const launchResponse: LaunchTicketResponse = JSON.parse( + await fs.readFile(path.join(fixturesDir, 'launch-response.json'), 'utf-8') + ) as LaunchTicketResponse; fetchStub.resolves( new Response(JSON.stringify(launchResponse), { status: 200 }) @@ -208,7 +228,7 @@ suite('API Client Test Suite', () => { // Verify the fetch call assert.ok(fetchStub.calledOnce); - const [url, init] = fetchStub.firstCall.args; + const [url, init] = fetchStub.firstCall.args as [string, FetchInit]; assert.strictEqual( url, 'http://localhost:7008/api/v1/tickets/FEAT-123/launch' @@ -216,7 +236,7 @@ suite('API Client Test Suite', () => { assert.strictEqual(init.method, 'POST'); assert.strictEqual(init.headers['Content-Type'], 'application/json'); - const body = JSON.parse(init.body); + const body = JSON.parse(init.body) as LaunchRequestBody; assert.strictEqual(body.provider, 'claude'); assert.strictEqual(body.model, 'sonnet'); assert.strictEqual(body.yolo_mode, true); @@ -259,7 +279,7 @@ suite('API Client Test Suite', () => { await client.launchTicket('FEAT-123/sub', options); - const [url] = fetchStub.firstCall.args; + const [url] = fetchStub.firstCall.args as [string]; assert.ok(url.includes('FEAT-123%2Fsub'), 'Should URL-encode slash'); }); @@ -342,7 +362,8 @@ suite('API Client Test Suite', () => { await client.launchTicket('FEAT-123', options as LaunchTicketRequest); - const body = JSON.parse(fetchStub.firstCall.args[1].body); + const [, init] = fetchStub.firstCall.args as [string, FetchInit]; + const body = JSON.parse(init.body) as LaunchRequestBody; assert.strictEqual(body.yolo_mode, false); }); }); @@ -351,9 +372,9 @@ suite('API Client Test Suite', () => { test('sends POST request and returns response', async () => { const client = new OperatorApiClient('http://localhost:7008'); - const pauseResponse: QueueControlResponse = await fs - .readFile(path.join(fixturesDir, 'queue-paused-response.json'), 'utf-8') - .then(JSON.parse); + const pauseResponse: QueueControlResponse = JSON.parse( + await fs.readFile(path.join(fixturesDir, 'queue-paused-response.json'), 'utf-8') + ) as QueueControlResponse; fetchStub.resolves( new Response(JSON.stringify(pauseResponse), { status: 200 }) @@ -362,7 +383,7 @@ suite('API Client Test Suite', () => { const result = await client.pauseQueue(); assert.ok(fetchStub.calledOnce); - const [url, init] = fetchStub.firstCall.args; + const [url, init] = fetchStub.firstCall.args as [string, FetchInit]; assert.strictEqual(url, 'http://localhost:7008/api/v1/queue/pause'); assert.strictEqual(init.method, 'POST'); @@ -391,12 +412,12 @@ suite('API Client Test Suite', () => { test('sends POST request and returns response', async () => { const client = new OperatorApiClient('http://localhost:7008'); - const resumeResponse: QueueControlResponse = await fs - .readFile( + const resumeResponse: QueueControlResponse = JSON.parse( + await fs.readFile( path.join(fixturesDir, 'queue-resumed-response.json'), 'utf-8' ) - .then(JSON.parse); + ) as QueueControlResponse; fetchStub.resolves( new Response(JSON.stringify(resumeResponse), { status: 200 }) @@ -405,7 +426,7 @@ suite('API Client Test Suite', () => { const result = await client.resumeQueue(); assert.ok(fetchStub.calledOnce); - const [url, init] = fetchStub.firstCall.args; + const [url, init] = fetchStub.firstCall.args as [string, FetchInit]; assert.strictEqual(url, 'http://localhost:7008/api/v1/queue/resume'); assert.strictEqual(init.method, 'POST'); @@ -434,9 +455,9 @@ suite('API Client Test Suite', () => { test('sends POST request and returns sync response', async () => { const client = new OperatorApiClient('http://localhost:7008'); - const syncResponse: KanbanSyncResponse = await fs - .readFile(path.join(fixturesDir, 'sync-response.json'), 'utf-8') - .then(JSON.parse); + const syncResponse: KanbanSyncResponse = JSON.parse( + await fs.readFile(path.join(fixturesDir, 'sync-response.json'), 'utf-8') + ) as KanbanSyncResponse; fetchStub.resolves( new Response(JSON.stringify(syncResponse), { status: 200 }) @@ -445,7 +466,7 @@ suite('API Client Test Suite', () => { const result = await client.syncKanban(); assert.ok(fetchStub.calledOnce); - const [url, init] = fetchStub.firstCall.args; + const [url, init] = fetchStub.firstCall.args as [string, FetchInit]; assert.strictEqual(url, 'http://localhost:7008/api/v1/queue/sync'); assert.strictEqual(init.method, 'POST'); @@ -479,12 +500,12 @@ suite('API Client Test Suite', () => { test('sends POST request with agent ID', async () => { const client = new OperatorApiClient('http://localhost:7008'); - const approveResponse: ReviewResponse = await fs - .readFile( + const approveResponse: ReviewResponse = JSON.parse( + await fs.readFile( path.join(fixturesDir, 'review-approved-response.json'), 'utf-8' ) - .then(JSON.parse); + ) as ReviewResponse; fetchStub.resolves( new Response(JSON.stringify(approveResponse), { status: 200 }) @@ -493,7 +514,7 @@ suite('API Client Test Suite', () => { const result = await client.approveReview('agent-abc123'); assert.ok(fetchStub.calledOnce); - const [url, init] = fetchStub.firstCall.args; + const [url, init] = fetchStub.firstCall.args as [string, FetchInit]; assert.strictEqual( url, 'http://localhost:7008/api/v1/agents/agent-abc123/approve' @@ -520,7 +541,7 @@ suite('API Client Test Suite', () => { await client.approveReview('agent/special'); - const [url] = fetchStub.firstCall.args; + const [url] = fetchStub.firstCall.args as [string]; assert.ok(url.includes('agent%2Fspecial'), 'Should URL-encode slash'); }); @@ -548,12 +569,12 @@ suite('API Client Test Suite', () => { test('sends POST request with agent ID and reason', async () => { const client = new OperatorApiClient('http://localhost:7008'); - const rejectResponse: ReviewResponse = await fs - .readFile( + const rejectResponse: ReviewResponse = JSON.parse( + await fs.readFile( path.join(fixturesDir, 'review-rejected-response.json'), 'utf-8' ) - .then(JSON.parse); + ) as ReviewResponse; fetchStub.resolves( new Response(JSON.stringify(rejectResponse), { status: 200 }) @@ -565,7 +586,7 @@ suite('API Client Test Suite', () => { ); assert.ok(fetchStub.calledOnce); - const [url, init] = fetchStub.firstCall.args; + const [url, init] = fetchStub.firstCall.args as [string, FetchInit]; assert.strictEqual( url, 'http://localhost:7008/api/v1/agents/agent-abc123/reject' @@ -573,7 +594,7 @@ suite('API Client Test Suite', () => { assert.strictEqual(init.method, 'POST'); assert.strictEqual(init.headers['Content-Type'], 'application/json'); - const body = JSON.parse(init.body); + const body = JSON.parse(init.body) as RejectRequestBody; assert.strictEqual(body.reason, 'Tests are failing'); assert.strictEqual(result.agent_id, 'agent-abc123'); @@ -596,7 +617,8 @@ suite('API Client Test Suite', () => { await client.rejectReview('agent-abc123', ''); - const body = JSON.parse(fetchStub.firstCall.args[1].body); + const [, init] = fetchStub.firstCall.args as [string, FetchInit]; + const body = JSON.parse(init.body) as RejectRequestBody; assert.strictEqual(body.reason, ''); }); diff --git a/vscode-extension/test/suite/extension.test.ts b/vscode-extension/test/suite/extension.test.ts index eaf1bd4..f18d856 100644 --- a/vscode-extension/test/suite/extension.test.ts +++ b/vscode-extension/test/suite/extension.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; suite('Extension Test Suite', () => { - vscode.window.showInformationMessage('Start all tests.'); + void vscode.window.showInformationMessage('Start all tests.'); test('Extension should be present', () => { assert.ok(vscode.extensions.getExtension('untra.operator-terminals')); diff --git a/vscode-extension/test/suite/index.ts b/vscode-extension/test/suite/index.ts index 0d5a0b9..b799bdc 100644 --- a/vscode-extension/test/suite/index.ts +++ b/vscode-extension/test/suite/index.ts @@ -4,9 +4,25 @@ import * as os from 'os'; import Mocha from 'mocha'; import { glob } from 'glob'; +/** + * Minimal type for the NYC coverage tool. + * NYC does not ship its own types, so we define the subset we use. + */ +interface NycInstance { + reset(): Promise; + wrap(): Promise; + exclude: { shouldInstrument(file: string): boolean }; + writeCoverageFile(): Promise; + report(): Promise; +} + +interface NycConstructor { + new (options: Record): NycInstance; +} + // NYC for coverage instrumentation inside VS Code process -// eslint-disable-next-line @typescript-eslint/no-require-imports -const NYC = require('nyc'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires +const NYC: NycConstructor = require('nyc') as NycConstructor; export async function run(): Promise { const testsRoot = path.resolve(__dirname, '.'); @@ -30,7 +46,7 @@ export async function run(): Promise { } // Setup NYC for coverage inside VS Code process - const nyc = new NYC({ + const nyc: NycInstance = new NYC({ cwd: workspaceRoot, reporter: ['text', 'lcov', 'html'], all: true, @@ -68,28 +84,30 @@ export async function run(): Promise { // Run the mocha test return new Promise((resolve, reject) => { - mocha.run(async (failures) => { - // Write coverage data - await nyc.writeCoverageFile(); - - // Generate and display coverage report - console.log('\n--- Coverage Report ---'); - await captureStdout(nyc.report.bind(nyc)); - - // Clean up test config if we created it - if (createdConfig) { - try { - fs.unlinkSync(configPath); - } catch { - // Ignore cleanup errors + mocha.run((failures) => { + // Write coverage data and report asynchronously, then resolve/reject + void (async () => { + await nyc.writeCoverageFile(); + + // Generate and display coverage report + console.log('\n--- Coverage Report ---'); + await captureStdout(() => nyc.report()); + + // Clean up test config if we created it + if (createdConfig) { + try { + fs.unlinkSync(configPath); + } catch { + // Ignore cleanup errors + } } - } - if (failures > 0) { - reject(new Error(`${failures} tests failed.`)); - } else { - resolve(); - } + if (failures > 0) { + reject(new Error(`${failures} tests failed.`)); + } else { + resolve(); + } + })(); }); }); } diff --git a/vscode-extension/test/suite/integration.test.ts b/vscode-extension/test/suite/integration.test.ts index bf172d0..5bb7744 100644 --- a/vscode-extension/test/suite/integration.test.ts +++ b/vscode-extension/test/suite/integration.test.ts @@ -13,7 +13,7 @@ suite('Integration Test Suite', () => { } }); - test('opr8r binary is bundled in extension', async () => { + test('opr8r binary is bundled in extension', () => { assert.ok(extension, 'Extension should be present'); const extensionPath = extension.extensionPath; @@ -27,7 +27,7 @@ suite('Integration Test Suite', () => { const files = fs.readdirSync(binDir); assert.ok( files.length > 0, - 'bin directory should contain files when present' + `bin directory should contain files when present (expected ${bundledPath})` ); } else { // Skip if no bin directory (development mode) @@ -91,18 +91,24 @@ suite('Integration Test Suite', () => { ); }); - test('Views are registered in sidebar', async () => { + test('Views are registered in sidebar', () => { assert.ok(extension, 'Extension should be present'); // Get the package.json contributes - const packageJson = extension.packageJSON; + const packageJson = extension.packageJSON as { + contributes?: { + views?: { + 'operator-sidebar'?: Array<{ id: string }>; + }; + }; + }; const views = packageJson.contributes?.views?.['operator-sidebar']; assert.ok(views, 'Sidebar views should be defined'); assert.ok(Array.isArray(views), 'Views should be an array'); // Verify expected views - const viewIds = views.map((v: { id: string }) => v.id); + const viewIds = views.map((v) => v.id); assert.ok(viewIds.includes('operator-status'), 'Status view should exist'); assert.ok(viewIds.includes('operator-in-progress'), 'In Progress view should exist'); assert.ok(viewIds.includes('operator-queue'), 'Queue view should exist'); diff --git a/vscode-extension/test/suite/issuetype-service.test.ts b/vscode-extension/test/suite/issuetype-service.test.ts index ce52241..a6fb38e 100644 --- a/vscode-extension/test/suite/issuetype-service.test.ts +++ b/vscode-extension/test/suite/issuetype-service.test.ts @@ -70,7 +70,7 @@ suite('IssueType Service Test Suite', () => { const customUrl = 'http://custom:9000'; fetchStub.resolves(new Response(JSON.stringify([]), { status: 200 })); - service.refresh(); + void service.refresh(); assert.ok( fetchStub.calledWith(`${customUrl}/api/v1/issuetypes`), @@ -378,7 +378,7 @@ suite('IssueType Service Test Suite', () => { // Verify by checking fetch calls fetchStub.resolves(new Response(JSON.stringify([]), { status: 200 })); - service.refresh(); + void service.refresh(); assert.ok( fetchStub.calledWith('http://newurl:9000/api/v1/issuetypes'), @@ -391,9 +391,9 @@ suite('IssueType Service Test Suite', () => { test('fetches issue types from API and updates types', async () => { service = new IssueTypeService(outputChannel, 'http://localhost:7008'); - const apiResponse: IssueTypeSummary[] = await fs - .readFile(path.join(fixturesDir, 'issuetypes-response.json'), 'utf-8') - .then(JSON.parse); + const apiResponse: IssueTypeSummary[] = JSON.parse( + await fs.readFile(path.join(fixturesDir, 'issuetypes-response.json'), 'utf-8') + ) as IssueTypeSummary[]; fetchStub.resolves( new Response(JSON.stringify(apiResponse), { status: 200 }) diff --git a/vscode-extension/test/suite/mcp-connect.test.ts b/vscode-extension/test/suite/mcp-connect.test.ts new file mode 100644 index 0000000..1c4b73b --- /dev/null +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for mcp-connect.ts + * + * Tests MCP descriptor fetching, deep link building, and the + * connectMcpServer flow. + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { + fetchMcpDescriptor, + buildMcpDeepLink, + McpDescriptorResponse, +} from '../../src/mcp-connect'; + +// Path to fixtures +const fixturesDir = path.join( + __dirname, + '..', + '..', + '..', + 'test', + 'fixtures', + 'api' +); + +/** Shape of the decoded MCP config embedded in deep link URIs */ +interface McpDeepLinkConfig { + name: string; + type: string; + url: string; +} + +suite('MCP Connect Test Suite', () => { + let fetchStub: sinon.SinonStub; + + setup(() => { + fetchStub = sinon.stub(global, 'fetch'); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('fetchMcpDescriptor()', () => { + test('fetches descriptor from correct URL', async () => { + const descriptorResponse: McpDescriptorResponse = JSON.parse( + await fs.readFile( + path.join(fixturesDir, 'mcp-descriptor-response.json'), + 'utf-8' + ) + ) as McpDescriptorResponse; + + fetchStub.resolves( + new Response(JSON.stringify(descriptorResponse), { status: 200 }) + ); + + const result = await fetchMcpDescriptor('http://localhost:7008'); + + assert.ok(fetchStub.calledOnce); + assert.strictEqual( + fetchStub.firstCall.args[0], + 'http://localhost:7008/api/v1/mcp/descriptor' + ); + assert.strictEqual(result.server_name, 'operator'); + assert.strictEqual(result.server_id, 'operator-mcp'); + assert.strictEqual(result.version, '0.1.26'); + assert.strictEqual( + result.transport_url, + 'http://localhost:7008/api/v1/mcp/sse' + ); + }); + + test('throws on network failure', async () => { + fetchStub.rejects(new Error('Connection refused')); + + await assert.rejects( + () => fetchMcpDescriptor('http://localhost:7008'), + /Operator API is not running/ + ); + }); + + test('throws on HTTP 404', async () => { + fetchStub.resolves(new Response('Not Found', { status: 404 })); + + await assert.rejects( + () => fetchMcpDescriptor('http://localhost:7008'), + /MCP descriptor unavailable/ + ); + }); + + test('throws on HTTP 500', async () => { + fetchStub.resolves( + new Response('Internal Server Error', { status: 500 }) + ); + + await assert.rejects( + () => fetchMcpDescriptor('http://localhost:7008'), + /MCP descriptor unavailable/ + ); + }); + + test('uses custom API URL', async () => { + const descriptorResponse: McpDescriptorResponse = { + server_name: 'operator', + server_id: 'operator-mcp', + version: '0.1.26', + transport_url: 'http://localhost:9999/api/v1/mcp/sse', + label: 'Operator MCP Server', + openapi_url: null, + }; + + fetchStub.resolves( + new Response(JSON.stringify(descriptorResponse), { status: 200 }) + ); + + await fetchMcpDescriptor('http://localhost:9999'); + + assert.strictEqual( + fetchStub.firstCall.args[0], + 'http://localhost:9999/api/v1/mcp/descriptor' + ); + }); + }); + + suite('buildMcpDeepLink()', () => { + test('builds correct vscode:// URI', () => { + const descriptor: McpDescriptorResponse = { + server_name: 'operator', + server_id: 'operator-mcp', + version: '0.1.26', + transport_url: 'http://localhost:7008/api/v1/mcp/sse', + label: 'Operator MCP Server', + openapi_url: 'http://localhost:7008/api-docs/openapi.json', + }; + + const uri = buildMcpDeepLink(descriptor); + + assert.strictEqual(uri.scheme, 'vscode'); + assert.strictEqual(uri.authority, 'modelcontextprotocol.mcp'); + assert.strictEqual(uri.path, '/connect'); + }); + + test('encodes correct config in base64', () => { + const descriptor: McpDescriptorResponse = { + server_name: 'operator', + server_id: 'operator-mcp', + version: '0.1.26', + transport_url: 'http://localhost:7008/api/v1/mcp/sse', + label: 'Operator MCP Server', + openapi_url: null, + }; + + const uri = buildMcpDeepLink(descriptor); + const query = uri.query; + + // Extract base64 config from query + assert.ok(query.startsWith('config='), 'Query should start with config='); + const base64 = query.replace('config=', ''); + const decoded = JSON.parse(Buffer.from(base64, 'base64').toString()) as McpDeepLinkConfig; + + assert.strictEqual(decoded.name, 'operator'); + assert.strictEqual(decoded.type, 'sse'); + assert.strictEqual( + decoded.url, + 'http://localhost:7008/api/v1/mcp/sse' + ); + }); + + test('uses server_name from descriptor', () => { + const descriptor: McpDescriptorResponse = { + server_name: 'custom-operator', + server_id: 'custom-mcp', + version: '1.0.0', + transport_url: 'http://localhost:9999/api/v1/mcp/sse', + label: 'Custom MCP', + openapi_url: null, + }; + + const uri = buildMcpDeepLink(descriptor); + const base64 = uri.query.replace('config=', ''); + const decoded = JSON.parse(Buffer.from(base64, 'base64').toString()) as McpDeepLinkConfig; + + assert.strictEqual(decoded.name, 'custom-operator'); + assert.strictEqual( + decoded.url, + 'http://localhost:9999/api/v1/mcp/sse' + ); + }); + }); +}); diff --git a/vscode-extension/test/suite/walkthrough.test.ts b/vscode-extension/test/suite/walkthrough.test.ts index 78b484f..9436999 100644 --- a/vscode-extension/test/suite/walkthrough.test.ts +++ b/vscode-extension/test/suite/walkthrough.test.ts @@ -119,10 +119,10 @@ suite('Walkthrough Test Suite', () => { assert.strictEqual(result.workspaces.length, 1); assert.strictEqual(result.anyConfigured, true); - assert.strictEqual(result.workspaces[0].provider, 'jira'); - assert.strictEqual(result.workspaces[0].name, 'mycompany.atlassian.net'); - assert.strictEqual(result.workspaces[0].url, 'https://mycompany.atlassian.net'); - assert.strictEqual(result.workspaces[0].configured, true); + assert.strictEqual(result.workspaces[0]!.provider, 'jira'); + assert.strictEqual(result.workspaces[0]!.name, 'mycompany.atlassian.net'); + assert.strictEqual(result.workspaces[0]!.url, 'https://mycompany.atlassian.net'); + assert.strictEqual(result.workspaces[0]!.configured, true); }); test('does not return jira workspace when only API key set (no domain)', () => { @@ -149,10 +149,10 @@ suite('Walkthrough Test Suite', () => { assert.strictEqual(result.workspaces.length, 1); assert.strictEqual(result.anyConfigured, true); - assert.strictEqual(result.workspaces[0].provider, 'linear'); - assert.strictEqual(result.workspaces[0].name, 'Linear'); - assert.strictEqual(result.workspaces[0].url, 'https://linear.app'); - assert.strictEqual(result.workspaces[0].configured, true); + assert.strictEqual(result.workspaces[0]!.provider, 'linear'); + assert.strictEqual(result.workspaces[0]!.name, 'Linear'); + assert.strictEqual(result.workspaces[0]!.url, 'https://linear.app'); + assert.strictEqual(result.workspaces[0]!.configured, true); }); test('returns linear workspace placeholder when LINEAR_API_KEY is set', () => { @@ -166,7 +166,7 @@ suite('Walkthrough Test Suite', () => { assert.strictEqual(result.workspaces.length, 1); assert.strictEqual(result.anyConfigured, true); - assert.strictEqual(result.workspaces[0].provider, 'linear'); + assert.strictEqual(result.workspaces[0]!.provider, 'linear'); }); test('returns both workspaces when both providers configured', () => { @@ -234,8 +234,8 @@ suite('Walkthrough Test Suite', () => { const result = await getKanbanWorkspaces(); assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].provider, 'jira'); - assert.strictEqual(result[0].url, 'https://example.atlassian.net'); + assert.strictEqual(result[0]!.provider, 'jira'); + assert.strictEqual(result[0]!.url, 'https://example.atlassian.net'); }); }); diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json index 297a00f..b8fb97a 100644 --- a/vscode-extension/tsconfig.json +++ b/vscode-extension/tsconfig.json @@ -6,6 +6,8 @@ "sourceMap": true, "outDir": "out", "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, diff --git a/vscode-extension/walkthrough/select-directory.md b/vscode-extension/walkthrough/select-directory.md index bdec9a7..59d6916 100644 --- a/vscode-extension/walkthrough/select-directory.md +++ b/vscode-extension/walkthrough/select-directory.md @@ -1,8 +1,8 @@ # Select Working Directory -Choose a **parent directory** that contains (or will contain) your project repositories. This is where the Operator server runs from. +Choose a **parent directory** that contains (or will contain) your project code repositories. This is where the Operator server runs from. -Selecting a directory runs `operator setup`, which writes a `config.toml` with server settings and creates the `.tickets/` structure for managing work items. +Selecting a directory runs `operator setup`, which writes a `config.toml` with server settings and creates the `.tickets/` structure for managing markdown shaped work items. The extension persists this path in your VS Code user settings so it works across all workspaces. @@ -15,7 +15,7 @@ The extension persists this path in your VS Code user settings so it works acros in-progress/ completed/ config.toml <- Server configuration - project-a/ <- Your repos + project-a/ <- Your other code repos project-b/ project-c/ ``` diff --git a/vscode-extension/webpack.webview.config.js b/vscode-extension/webpack.webview.config.js index 7921e09..2efe28b 100644 --- a/vscode-extension/webpack.webview.config.js +++ b/vscode-extension/webpack.webview.config.js @@ -33,6 +33,13 @@ const config = { test: /\.css$/, use: ['style-loader', 'css-loader'], }, + { + test: /\.woff$/, + type: 'asset/resource', + generator: { + filename: '[name][ext]', + }, + }, ], }, performance: { diff --git a/vscode-extension/webview-ui/App.tsx b/vscode-extension/webview-ui/App.tsx index 7f34499..25ca833 100644 --- a/vscode-extension/webview-ui/App.tsx +++ b/vscode-extension/webview-ui/App.tsx @@ -13,10 +13,16 @@ import type { JiraValidationInfo, LinearValidationInfo, ProjectSummary, + IssueTypeSummary, + IssueTypeResponse, + CollectionResponse, + ExternalIssueTypeSummary, } from './types/messages'; import type { JiraConfig } from '../src/generated/JiraConfig'; import type { LinearConfig } from '../src/generated/LinearConfig'; import type { ProjectSyncConfig } from '../src/generated/ProjectSyncConfig'; +import type { CreateIssueTypeRequest } from '../src/generated/CreateIssueTypeRequest'; +import type { UpdateIssueTypeRequest } from '../src/generated/UpdateIssueTypeRequest'; export function App() { const [config, setConfig] = useState(null); @@ -29,6 +35,16 @@ export function App() { const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); + const [issueTypes, setIssueTypes] = useState([]); + const [issueTypesLoading, setIssueTypesLoading] = useState(false); + const [selectedIssueType, setSelectedIssueType] = useState(null); + const [collections, setCollections] = useState([]); + const [collectionsLoading, setCollectionsLoading] = useState(false); + const [externalIssueTypes, setExternalIssueTypes] = useState>(new Map()); + const [issueTypeError, setIssueTypeError] = useState(null); + const [collectionsError, setCollectionsError] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [drawerMode, setDrawerMode] = useState<'view' | 'edit' | 'create'>('view'); useEffect(() => { const cleanup = onMessage((msg: ExtensionToWebviewMessage) => { @@ -66,6 +82,10 @@ export function App() { if (msg.reachable) { setProjectsLoading(true); postMessage({ type: 'getProjects' }); + setIssueTypesLoading(true); + postMessage({ type: 'getIssueTypes' }); + setCollectionsLoading(true); + postMessage({ type: 'getCollections' }); } break; case 'projectsLoaded': @@ -84,6 +104,76 @@ export function App() { case 'assessTicketError': setProjectsError(`Failed to assess ${msg.projectName}: ${msg.error}`); break; + case 'issueTypesLoaded': + setIssueTypes(msg.issueTypes); + setIssueTypesLoading(false); + setIssueTypeError(null); + break; + case 'issueTypeLoaded': + setSelectedIssueType(msg.issueType); + break; + case 'issueTypeError': + setIssueTypeError(msg.error); + setIssueTypesLoading(false); + break; + case 'collectionsLoaded': + setCollections(msg.collections); + setCollectionsLoading(false); + setCollectionsError(null); + break; + case 'collectionActivated': + // Refresh issue types after collection change + postMessage({ type: 'getIssueTypes' }); + break; + case 'collectionsError': + setCollectionsError(msg.error); + setCollectionsLoading(false); + break; + case 'externalIssueTypesLoaded': + setExternalIssueTypes(prev => { + const next = new Map(prev); + next.set(`${msg.provider}/${msg.projectKey}`, msg.types); + return next; + }); + break; + case 'externalIssueTypesError': + setIssueTypeError(msg.error); + break; + case 'issueTypeCreated': + setIssueTypes(prev => [...prev, { + key: msg.issueType.key, + name: msg.issueType.name, + description: msg.issueType.description, + mode: msg.issueType.mode, + glyph: msg.issueType.glyph, + color: msg.issueType.color ?? undefined, + source: msg.issueType.source, + stepCount: msg.issueType.steps.length, + }]); + setSelectedIssueType(msg.issueType); + break; + case 'issueTypeUpdated': + setIssueTypes(prev => prev.map(it => + it.key === msg.issueType.key ? { + key: msg.issueType.key, + name: msg.issueType.name, + description: msg.issueType.description, + mode: msg.issueType.mode, + glyph: msg.issueType.glyph, + color: msg.issueType.color ?? undefined, + source: msg.issueType.source, + stepCount: msg.issueType.steps.length, + } : it + )); + setSelectedIssueType(msg.issueType); + break; + case 'issueTypeDeleted': + setIssueTypes(prev => prev.filter(it => it.key !== msg.key)); + if (selectedIssueType?.key === msg.key) { + setSelectedIssueType(null); + setDrawerOpen(false); + } + break; } }); @@ -149,6 +239,50 @@ export function App() { postMessage({ type: 'openProjectFolder', projectPath }); }, []); + const handleGetIssueTypes = useCallback(() => { + setIssueTypesLoading(true); + postMessage({ type: 'getIssueTypes' }); + }, []); + + const handleGetIssueType = useCallback((key: string) => { + postMessage({ type: 'getIssueType', key }); + }, []); + + const handleGetCollections = useCallback(() => { + setCollectionsLoading(true); + postMessage({ type: 'getCollections' }); + }, []); + + const handleActivateCollection = useCallback((name: string) => { + postMessage({ type: 'activateCollection', name }); + }, []); + + const handleGetExternalIssueTypes = useCallback((provider: string, domain: string, projectKey: string) => { + postMessage({ type: 'getExternalIssueTypes', provider, domain, projectKey }); + }, []); + + const handleCreateIssueType = useCallback((request: CreateIssueTypeRequest) => { + postMessage({ type: 'createIssueType', request }); + }, []); + + const handleUpdateIssueType = useCallback((key: string, request: UpdateIssueTypeRequest) => { + postMessage({ type: 'updateIssueType', key, request }); + }, []); + + const handleDeleteIssueType = useCallback((key: string) => { + postMessage({ type: 'deleteIssueType', key }); + }, []); + + const handleOpenDrawer = useCallback((mode: 'view' | 'edit' | 'create', issueType?: IssueTypeResponse) => { + setDrawerMode(mode); + setSelectedIssueType(issueType ?? null); + setDrawerOpen(true); + }, []); + + const handleCloseDrawer = useCallback(() => { + setDrawerOpen(false); + }, []); + return ( {error && ( @@ -176,6 +310,26 @@ export function App() { onAssessProject={handleAssessProject} onRefreshProjects={handleRefreshProjects} onOpenProject={handleOpenProject} + issueTypes={issueTypes} + issueTypesLoading={issueTypesLoading} + issueTypeError={issueTypeError} + collections={collections} + collectionsLoading={collectionsLoading} + collectionsError={collectionsError} + externalIssueTypes={externalIssueTypes} + selectedIssueType={selectedIssueType} + drawerOpen={drawerOpen} + drawerMode={drawerMode} + onGetIssueTypes={handleGetIssueTypes} + onGetIssueType={handleGetIssueType} + onGetCollections={handleGetCollections} + onActivateCollection={handleActivateCollection} + onGetExternalIssueTypes={handleGetExternalIssueTypes} + onCreateIssueType={handleCreateIssueType} + onUpdateIssueType={handleUpdateIssueType} + onDeleteIssueType={handleDeleteIssueType} + onOpenDrawer={handleOpenDrawer} + onCloseDrawer={handleCloseDrawer} /> ) : ( @@ -227,7 +381,7 @@ function deepMerge>(target: T, source: T): T { const DEFAULT_JIRA: JiraConfig = { enabled: false, api_key_env: 'OPERATOR_JIRA_API_KEY', email: '', projects: {} }; const DEFAULT_LINEAR: LinearConfig = { enabled: false, api_key_env: 'OPERATOR_LINEAR_API_KEY', projects: {} }; -const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { sync_user_id: '', sync_statuses: [], collection_name: '' }; +const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { sync_user_id: '', sync_statuses: [], collection_name: '', type_mappings: {} }; /** Apply an update to the config object by section/key path */ function applyUpdate( @@ -269,7 +423,7 @@ function applyUpdate( } else if (key === 'domain' && typeof value === 'string' && value !== domain) { delete jiraMap[domain]; jiraMap[value] = ws; - } else if (key === 'project_key' || key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id') { + } else if (key === 'project_key' || key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { const projects = { ...ws.projects }; const pKeys = Object.keys(projects); const pKey = pKeys[0] ?? 'default'; @@ -284,6 +438,19 @@ function applyUpdate( } ws.projects = projects; jiraMap[domain] = ws; + } else if (key.startsWith('projects.')) { + // Multi-project writes: projects.{projectKey}.{field} + const parts = key.split('.'); + if (parts.length >= 3) { + const pKey = parts[1]; + const field = parts.slice(2).join('.'); + const projects = { ...ws.projects }; + const existing = { ...(projects[pKey] ?? DEFAULT_PROJECT_SYNC) }; + (existing as Record)[field] = value; + projects[pKey] = existing; + ws.projects = projects; + jiraMap[domain] = ws; + } } next.config.kanban = { ...next.config.kanban, jira: jiraMap }; break; @@ -301,7 +468,7 @@ function applyUpdate( } else if (key === 'team_id' && typeof value === 'string' && value !== teamId) { delete linearMap[teamId]; linearMap[value] = ws; - } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id') { + } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { const projects = { ...ws.projects }; const pKeys = Object.keys(projects); const pKey = pKeys[0] ?? 'default'; @@ -310,6 +477,19 @@ function applyUpdate( projects[pKey] = existing; ws.projects = projects; linearMap[teamId] = ws; + } else if (key.startsWith('projects.')) { + // Multi-project writes: projects.{projectKey}.{field} + const parts = key.split('.'); + if (parts.length >= 3) { + const pKey = parts[1]; + const field = parts.slice(2).join('.'); + const projects = { ...ws.projects }; + const existing = { ...(projects[pKey] ?? DEFAULT_PROJECT_SYNC) }; + (existing as Record)[field] = value; + projects[pKey] = existing; + ws.projects = projects; + linearMap[teamId] = ws; + } } next.config.kanban = { ...next.config.kanban, linear: linearMap }; break; diff --git a/vscode-extension/webview-ui/components/ConfigPage.tsx b/vscode-extension/webview-ui/components/ConfigPage.tsx index 7cb6aa3..d12b700 100644 --- a/vscode-extension/webview-ui/components/ConfigPage.tsx +++ b/vscode-extension/webview-ui/components/ConfigPage.tsx @@ -9,7 +9,18 @@ import { CodingAgentsSection } from './sections/CodingAgentsSection'; import { KanbanProvidersSection } from './sections/KanbanProvidersSection'; import { GitRepositoriesSection } from './sections/GitRepositoriesSection'; import { ProjectsSection } from './sections/ProjectsSection'; -import type { WebviewConfig, JiraValidationInfo, LinearValidationInfo, ProjectSummary } from '../types/messages'; +import type { + WebviewConfig, + JiraValidationInfo, + LinearValidationInfo, + ProjectSummary, + IssueTypeSummary, + IssueTypeResponse, + CollectionResponse, + ExternalIssueTypeSummary, +} from '../types/messages'; +import type { CreateIssueTypeRequest } from '../../src/generated/CreateIssueTypeRequest'; +import type { UpdateIssueTypeRequest } from '../../src/generated/UpdateIssueTypeRequest'; interface ConfigPageProps { config: WebviewConfig; @@ -30,6 +41,26 @@ interface ConfigPageProps { onAssessProject: (name: string) => void; onRefreshProjects: () => void; onOpenProject: (path: string) => void; + issueTypes: IssueTypeSummary[]; + issueTypesLoading: boolean; + issueTypeError: string | null; + collections: CollectionResponse[]; + collectionsLoading: boolean; + collectionsError: string | null; + externalIssueTypes: Map; + selectedIssueType: IssueTypeResponse | null; + drawerOpen: boolean; + drawerMode: 'view' | 'edit' | 'create'; + onGetIssueTypes: () => void; + onGetIssueType: (key: string) => void; + onGetCollections: () => void; + onActivateCollection: (name: string) => void; + onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + onCreateIssueType: (request: CreateIssueTypeRequest) => void; + onUpdateIssueType: (key: string, request: UpdateIssueTypeRequest) => void; + onDeleteIssueType: (key: string) => void; + onOpenDrawer: (mode: 'view' | 'edit' | 'create', issueType?: IssueTypeResponse) => void; + onCloseDrawer: () => void; } export function ConfigPage({ @@ -51,6 +82,26 @@ export function ConfigPage({ onAssessProject, onRefreshProjects, onOpenProject, + issueTypes, + issueTypesLoading, + issueTypeError, + collections, + collectionsLoading, + collectionsError, + externalIssueTypes, + selectedIssueType, + drawerOpen, + drawerMode, + onGetIssueTypes, + onGetIssueType, + onGetCollections, + onActivateCollection, + onGetExternalIssueTypes, + onCreateIssueType, + onUpdateIssueType, + onDeleteIssueType, + onOpenDrawer, + onCloseDrawer, }: ConfigPageProps) { const scrollRef = useRef(null); const hasWorkDir = Boolean(config.working_directory); @@ -111,6 +162,27 @@ export function ConfigPage({ linearResult={linearResult} validatingJira={validatingJira} validatingLinear={validatingLinear} + apiReachable={apiReachable} + issueTypes={issueTypes} + issueTypesLoading={issueTypesLoading} + issueTypeError={issueTypeError} + collections={collections} + collectionsLoading={collectionsLoading} + collectionsError={collectionsError} + externalIssueTypes={externalIssueTypes} + selectedIssueType={selectedIssueType} + drawerOpen={drawerOpen} + drawerMode={drawerMode} + onGetIssueTypes={onGetIssueTypes} + onGetIssueType={onGetIssueType} + onGetCollections={onGetCollections} + onActivateCollection={onActivateCollection} + onGetExternalIssueTypes={onGetExternalIssueTypes} + onCreateIssueType={onCreateIssueType} + onUpdateIssueType={onUpdateIssueType} + onDeleteIssueType={onDeleteIssueType} + onOpenDrawer={onOpenDrawer} + onCloseDrawer={onCloseDrawer} /> void; + onGetCollections: () => void; + onViewIssueType: (key: string) => void; + onCreateIssueType: () => void; +} + +export function CollectionsSubSection({ + collections, + collectionsLoading, + collectionsError, + issueTypes, + onActivateCollection, + onGetCollections, + onViewIssueType, + onCreateIssueType, +}: CollectionsSubSectionProps) { + const [expandedCollection, setExpandedCollection] = useState(null); + + if (collectionsLoading) { + return ( + + + Loading collections... + + ); + } + + return ( + + + + Collections & Issue Types + + + + + + + + {collectionsError && ( + {collectionsError} + )} + + {collections.length === 0 ? ( + + No collections available. Start the Operator API to manage collections. + + ) : ( + + {collections.map((collection) => ( + + + setExpandedCollection( + expandedCollection === collection.name ? null : collection.name + )} + > + + {collection.name} + + {collection.is_active && ( + + )} + + {collection.description && ( + + {collection.description} + + )} + {!collection.is_active && ( + + )} + + + + + {collection.types.map((typeKey) => { + const type = issueTypes.find(t => t.key === typeKey); + return ( + + {type?.glyph && {type.glyph}} + {typeKey} + {type && · {type.name}} + + } + size="small" + variant="outlined" + onClick={() => onViewIssueType(typeKey)} + sx={{ cursor: 'pointer' }} + /> + ); + })} + + + + + ))} + + )} + + ); +} diff --git a/vscode-extension/webview-ui/components/kanban/FieldEditor.tsx b/vscode-extension/webview-ui/components/kanban/FieldEditor.tsx new file mode 100644 index 0000000..efa5239 --- /dev/null +++ b/vscode-extension/webview-ui/components/kanban/FieldEditor.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import type { CreateFieldRequest } from '../../../src/generated/CreateFieldRequest'; + +interface FieldEditorProps { + field: CreateFieldRequest; + index: number; + onChange: (index: number, field: CreateFieldRequest) => void; + onRemove: (index: number) => void; + readOnly?: boolean; +} + +const FIELD_TYPES = [ + { value: 'string', label: 'String' }, + { value: 'text', label: 'Text (multiline)' }, + { value: 'enum', label: 'Enum (options)' }, + { value: 'bool', label: 'Boolean' }, + { value: 'date', label: 'Date' }, + { value: 'integer', label: 'Integer' }, +]; + +export function FieldEditor({ field, index, onChange, onRemove, readOnly }: FieldEditorProps) { + const update = (patch: Partial) => { + onChange(index, { ...field, ...patch }); + }; + + return ( + + + + Field {index + 1} + + {!readOnly && ( + onRemove(index)} sx={{ p: 0.5 }}> + + + )} + + + + update({ name: e.target.value })} + disabled={readOnly} + sx={{ flex: 1 }} + /> + + Type + + + + + update({ description: e.target.value })} + disabled={readOnly} + fullWidth + sx={{ mb: 1 }} + /> + + {field.field_type === 'enum' && ( + update({ options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} + disabled={readOnly} + fullWidth + sx={{ mb: 1 }} + /> + )} + + + update({ default: e.target.value || undefined })} + disabled={readOnly} + sx={{ flex: 1 }} + /> + update({ placeholder: e.target.value || undefined })} + disabled={readOnly} + sx={{ flex: 1 }} + /> + + + + update({ required: e.target.checked })} + disabled={readOnly} + /> + } + label={Required} + /> + update({ user_editable: e.target.checked })} + disabled={readOnly} + /> + } + label={User Editable} + /> + + + ); +} diff --git a/vscode-extension/webview-ui/components/kanban/IssueTypeDrawer.tsx b/vscode-extension/webview-ui/components/kanban/IssueTypeDrawer.tsx new file mode 100644 index 0000000..0d59bf7 --- /dev/null +++ b/vscode-extension/webview-ui/components/kanban/IssueTypeDrawer.tsx @@ -0,0 +1,420 @@ +import React, { useState, useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Drawer from '@mui/material/Drawer'; +import Typography from '@mui/material/Typography'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; +import Divider from '@mui/material/Divider'; +import Alert from '@mui/material/Alert'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import { WorkflowPreview } from './WorkflowPreview'; +import { FieldEditor } from './FieldEditor'; +import { StepEditor } from './StepEditor'; +import type { IssueTypeResponse } from '../../../src/generated/IssueTypeResponse'; +import type { CreateIssueTypeRequest } from '../../../src/generated/CreateIssueTypeRequest'; +import type { UpdateIssueTypeRequest } from '../../../src/generated/UpdateIssueTypeRequest'; +import type { CreateFieldRequest } from '../../../src/generated/CreateFieldRequest'; +import type { CreateStepRequest } from '../../../src/generated/CreateStepRequest'; + +interface IssueTypeDrawerProps { + open: boolean; + mode: 'view' | 'edit' | 'create'; + issueType: IssueTypeResponse | null; + onClose: () => void; + onCreate: (request: CreateIssueTypeRequest) => void; + onUpdate: (key: string, request: UpdateIssueTypeRequest) => void; + onDelete: (key: string) => void; +} + +const DEFAULT_FIELD: CreateFieldRequest = { + name: '', + description: '', + field_type: 'string', + required: false, + default: null, + options: [], + placeholder: null, + max_length: null, + user_editable: true, +}; + +const DEFAULT_STEP: CreateStepRequest = { + name: '', + display_name: null, + prompt: '', + outputs: [], + allowed_tools: ['*'], + review_type: 'none', + next_step: null, + permission_mode: 'default', +}; + +function generateKey(name: string): string { + return name + .replace(/[^a-zA-Z0-9]/g, '') + .toUpperCase() + .substring(0, 10); +} + +export function IssueTypeDrawer({ + open, + mode, + issueType, + onClose, + onCreate, + onUpdate, + onDelete, +}: IssueTypeDrawerProps) { + const [key, setKey] = useState(''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [issueMode, setIssueMode] = useState('autonomous'); + const [glyph, setGlyph] = useState(''); + const [color, setColor] = useState(''); + const [projectRequired, setProjectRequired] = useState(true); + const [fields, setFields] = useState([]); + const [steps, setSteps] = useState([]); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [autoKey, setAutoKey] = useState(true); + + const isBuiltin = issueType?.source === 'builtin'; + const readOnly = mode === 'view' || isBuiltin; + const isCreate = mode === 'create'; + + useEffect(() => { + if (issueType && mode !== 'create') { + setKey(issueType.key); + setName(issueType.name); + setDescription(issueType.description); + setIssueMode(issueType.mode); + setGlyph(issueType.glyph); + setColor(issueType.color ?? ''); + setProjectRequired(issueType.project_required); + setFields(issueType.fields.map(f => ({ + name: f.name, + description: f.description, + field_type: f.field_type, + required: f.required, + default: f.default ?? null, + options: f.options, + placeholder: f.placeholder ?? null, + max_length: f.max_length ?? null, + user_editable: f.user_editable, + }))); + setSteps(issueType.steps.map(s => ({ + name: s.name, + display_name: s.display_name ?? null, + prompt: s.prompt, + outputs: s.outputs, + allowed_tools: s.allowed_tools, + review_type: s.review_type, + next_step: s.next_step ?? null, + permission_mode: s.permission_mode, + }))); + setAutoKey(false); + } else if (isCreate) { + setKey(''); + setName(''); + setDescription(''); + setIssueMode('autonomous'); + setGlyph(''); + setColor(''); + setProjectRequired(true); + setFields([]); + setSteps([{ ...DEFAULT_STEP, name: 'execute', prompt: '' }]); + setAutoKey(true); + } + }, [issueType, mode, isCreate]); + + const handleSave = () => { + if (isCreate) { + const request: CreateIssueTypeRequest = { + key, + name, + description, + mode: issueMode, + glyph: glyph || name.charAt(0).toUpperCase(), + color: color || null, + project_required: projectRequired, + fields, + steps, + }; + onCreate(request); + } else if (issueType) { + const request: UpdateIssueTypeRequest = { + name, + description, + mode: issueMode, + glyph: glyph || null, + color: color || null, + project_required: projectRequired, + fields, + steps, + }; + onUpdate(issueType.key, request); + } + onClose(); + }; + + const handleFieldChange = (index: number, field: CreateFieldRequest) => { + setFields(prev => prev.map((f, i) => i === index ? field : f)); + }; + + const handleStepChange = (index: number, step: CreateStepRequest) => { + setSteps(prev => prev.map((s, i) => i === index ? step : s)); + }; + + return ( + + {/* Header */} + + + {isCreate ? 'Create Issue Type' : readOnly ? 'Issue Type Details' : 'Edit Issue Type'} + + {issueType && ( + + Source: {issueType.source} + + )} + + + {isBuiltin && mode === 'view' && ( + + Builtin types are read-only. + + )} + + {/* Overview */} + + { + setKey(e.target.value.toUpperCase()); + setAutoKey(false); + }} + disabled={!isCreate} + sx={{ flex: 1 }} + /> + { + setName(e.target.value); + if (isCreate && autoKey) { + setKey(generateKey(e.target.value)); + } + }} + disabled={readOnly} + sx={{ flex: 2 }} + /> + setGlyph(e.target.value)} + disabled={readOnly} + sx={{ width: 60 }} + inputProps={{ maxLength: 2 }} + /> + + + setDescription(e.target.value)} + disabled={readOnly} + fullWidth + multiline + minRows={2} + sx={{ mb: 2 }} + /> + + + + Mode + + + + setColor(e.target.value)} + disabled={readOnly} + placeholder="#66AA99" + sx={{ width: 120 }} + /> + + setProjectRequired(e.target.checked)} + disabled={readOnly} + /> + } + label={Project Required} + /> + + + + + {/* Fields */} + + + Fields ({fields.length}) + {!readOnly && ( + + )} + + {fields.map((field, i) => ( + setFields(fields.filter((_, j) => j !== idx))} + readOnly={readOnly} + /> + ))} + {fields.length === 0 && ( + No fields defined + )} + + + + + {/* Steps / Workflow */} + + + Workflow Steps ({steps.length}) + {!readOnly && ( + + )} + + + {/* Preview */} + {steps.length > 0 && readOnly && ( + + ({ + name: s.name, + display_name: s.display_name ?? null, + prompt: s.prompt, + outputs: s.outputs, + allowed_tools: s.allowed_tools, + review_type: s.review_type, + next_step: s.next_step ?? null, + permission_mode: s.permission_mode, + }))} + /> + + )} + + {/* Editors */} + {!readOnly && steps.map((step, i) => ( + s.name)} + onChange={handleStepChange} + onRemove={(idx) => setSteps(steps.filter((_, j) => j !== idx))} + readOnly={readOnly} + /> + ))} + {steps.length === 0 && ( + No workflow steps defined + )} + + + {/* Footer */} + + {!readOnly && !isBuiltin && issueType && ( + + )} + + {!readOnly && ( + + )} + + + {/* Delete confirmation */} + setDeleteConfirmOpen(false)}> + Delete Issue Type + + + Are you sure you want to delete {issueType?.key}? This cannot be undone. + + + + + + + + + ); +} diff --git a/vscode-extension/webview-ui/components/kanban/MappingPanel.tsx b/vscode-extension/webview-ui/components/kanban/MappingPanel.tsx new file mode 100644 index 0000000..46c7274 --- /dev/null +++ b/vscode-extension/webview-ui/components/kanban/MappingPanel.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; +import Alert from '@mui/material/Alert'; +import { MappingRow } from './MappingRow'; +import type { ExternalIssueTypeSummary, IssueTypeSummary, IssueTypeResponse } from '../../types/messages'; + +interface MappingPanelProps { + provider: string; + domain: string; + projectKey: string; + collectionName: string; + typeMappings: { [key: string]: string | undefined }; + issueTypes: IssueTypeSummary[]; + externalTypes: ExternalIssueTypeSummary[] | undefined; + onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + onMappingChange: (externalName: string, operatorKey: string | '') => void; + onViewIssueType: (key: string) => void; + selectedIssueType: IssueTypeResponse | null; +} + +function autoMap(externalName: string, operatorTypes: IssueTypeSummary[]): string | null { + const name = externalName.toLowerCase(); + const rules: [RegExp, string][] = [ + [/bug|defect|fix|issue/, 'FIX'], + [/story|feature|enhancement/, 'FEAT'], + [/task|subtask|item|card/, 'TASK'], + [/spike|research|milestone/, 'SPIKE'], + [/incident|investigation|initiative/, 'INV'], + ]; + for (const [pattern, key] of rules) { + if (pattern.test(name) && operatorTypes.some(t => t.key === key)) { + return key; + } + } + return null; +} + +export function MappingPanel({ + provider, + domain, + projectKey, + collectionName, + typeMappings, + issueTypes, + externalTypes, + onGetExternalIssueTypes, + onMappingChange, + onViewIssueType, + selectedIssueType, +}: MappingPanelProps) { + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!externalTypes) { + setLoading(true); + onGetExternalIssueTypes(provider, domain, projectKey); + } + }, [provider, domain, projectKey, externalTypes, onGetExternalIssueTypes]); + + useEffect(() => { + if (externalTypes) { + setLoading(false); + } + }, [externalTypes]); + + const autoMappings = useMemo(() => { + const map = new Map(); + if (externalTypes) { + for (const et of externalTypes) { + map.set(et.name, autoMap(et.name, issueTypes)); + } + } + return map; + }, [externalTypes, issueTypes]); + + if (loading || !externalTypes) { + return ( + + + + Loading issue types from {provider}... + + + ); + } + + if (externalTypes.length === 0) { + return ( + + No issue types found in {provider} project {projectKey} + + ); + } + + return ( + + + Issue Type Mappings for {projectKey} + {collectionName && ` (collection: ${collectionName})`} + + {externalTypes.map((et) => { + const autoKey = autoMappings.get(et.name) ?? null; + const overrideKey = typeMappings[et.name] ?? null; + return ( + + ); + })} + + ); +} diff --git a/vscode-extension/webview-ui/components/kanban/MappingRow.tsx b/vscode-extension/webview-ui/components/kanban/MappingRow.tsx new file mode 100644 index 0000000..e5aae33 --- /dev/null +++ b/vscode-extension/webview-ui/components/kanban/MappingRow.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import { WorkflowPreview } from './WorkflowPreview'; +import type { ExternalIssueTypeSummary, IssueTypeSummary } from '../../types/messages'; +import type { IssueTypeResponse } from '../../../src/generated/IssueTypeResponse'; + +interface MappingRowProps { + external: ExternalIssueTypeSummary; + operatorTypes: IssueTypeSummary[]; + selectedKey: string | null; + autoMatchedKey: string | null; + selectedIssueTypeDetail: IssueTypeResponse | null; + onSelect: (externalName: string, operatorKey: string | '') => void; + onViewIssueType: (key: string) => void; +} + +export function MappingRow({ + external, + operatorTypes, + selectedKey, + autoMatchedKey, + selectedIssueTypeDetail, + onSelect, + onViewIssueType, +}: MappingRowProps) { + const effectiveKey = selectedKey ?? autoMatchedKey; + const isOverride = selectedKey !== null && selectedKey !== autoMatchedKey; + const matchedType = operatorTypes.find(t => t.key === effectiveKey); + + return ( + + + {/* External type */} + + {external.icon_url && ( + + )} + + {external.name} + + + + {/* Arrow */} + + + {/* Operator type selector */} + + + + + {autoMatchedKey && !isOverride && ( + + auto-matched + + )} + {isOverride && ( + + custom override + + )} + + + + {/* Workflow preview for matched type */} + {matchedType && selectedIssueTypeDetail && selectedIssueTypeDetail.key === effectiveKey && ( + onViewIssueType(effectiveKey!)} + > + + + )} + + ); +} diff --git a/vscode-extension/webview-ui/components/kanban/ProjectRow.tsx b/vscode-extension/webview-ui/components/kanban/ProjectRow.tsx new file mode 100644 index 0000000..3dcaf03 --- /dev/null +++ b/vscode-extension/webview-ui/components/kanban/ProjectRow.tsx @@ -0,0 +1,142 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import TextField from '@mui/material/TextField'; +import Chip from '@mui/material/Chip'; +import Collapse from '@mui/material/Collapse'; +import { MappingPanel } from './MappingPanel'; +import type { ProjectSyncConfig } from '../../../src/generated/ProjectSyncConfig'; +import type { IssueTypeSummary, CollectionResponse, ExternalIssueTypeSummary, IssueTypeResponse } from '../../types/messages'; + +interface ProjectRowProps { + provider: string; + domain: string; + projectKey: string; + project: ProjectSyncConfig; + collections: CollectionResponse[]; + issueTypes: IssueTypeSummary[]; + externalTypes: ExternalIssueTypeSummary[] | undefined; + selectedIssueType: IssueTypeResponse | null; + onUpdate: (section: string, key: string, value: unknown) => void; + onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + onViewIssueType: (key: string) => void; + sectionKey: string; +} + +export function ProjectRow({ + provider, + domain, + projectKey, + project, + collections, + issueTypes, + externalTypes, + selectedIssueType, + onUpdate, + onGetExternalIssueTypes, + onViewIssueType, + sectionKey, +}: ProjectRowProps) { + const [expanded, setExpanded] = useState(false); + + const mappingCount = Object.keys(project.type_mappings ?? {}).length; + + const handleMappingChange = (externalName: string, operatorKey: string | '') => { + const newMappings = { ...(project.type_mappings ?? {}) }; + if (operatorKey === '') { + delete newMappings[externalName]; + } else { + newMappings[externalName] = operatorKey; + } + onUpdate(sectionKey, `projects.${projectKey}.type_mappings`, newMappings); + }; + + return ( + + setExpanded(!expanded)} + > + + {projectKey} + + + e.stopPropagation()}> + Collection + + + + e.stopPropagation()}> + {(project.sync_statuses ?? []).map((status) => ( + + ))} + + + {mappingCount > 0 && ( + + )} + + + + + + + + + { + const statuses = e.target.value.split(',').map((s) => s.trim()).filter(Boolean); + onUpdate(sectionKey, `projects.${projectKey}.sync_statuses`, statuses); + }} + placeholder="To Do, In Progress" + fullWidth + sx={{ mb: 1 }} + helperText="Workflow statuses to sync (comma-separated)" + /> + + + + + + ); +} diff --git a/vscode-extension/webview-ui/components/kanban/ProviderCard.tsx b/vscode-extension/webview-ui/components/kanban/ProviderCard.tsx new file mode 100644 index 0000000..39adc46 --- /dev/null +++ b/vscode-extension/webview-ui/components/kanban/ProviderCard.tsx @@ -0,0 +1,284 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import Switch from '@mui/material/Switch'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Alert from '@mui/material/Alert'; +import CircularProgress from '@mui/material/CircularProgress'; +import Collapse from '@mui/material/Collapse'; +import { ProjectRow } from './ProjectRow'; +import type { JiraConfig } from '../../../src/generated/JiraConfig'; +import type { LinearConfig } from '../../../src/generated/LinearConfig'; +import type { ProjectSyncConfig } from '../../../src/generated/ProjectSyncConfig'; +import type { + JiraValidationInfo, + LinearValidationInfo, + IssueTypeSummary, + CollectionResponse, + ExternalIssueTypeSummary, + IssueTypeResponse, +} from '../../types/messages'; + +interface ProviderCardProps { + type: 'jira' | 'linear'; + domain: string; + config: JiraConfig | LinearConfig; + onUpdate: (section: string, key: string, value: unknown) => void; + onValidate: (...args: string[]) => void; + validationResult: JiraValidationInfo | LinearValidationInfo | null; + validating: boolean; + collections: CollectionResponse[]; + issueTypes: IssueTypeSummary[]; + externalIssueTypes: Map; + selectedIssueType: IssueTypeResponse | null; + onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + onViewIssueType: (key: string) => void; +} + +export function ProviderCard({ + type, + domain, + config, + onUpdate, + onValidate, + validationResult, + validating, + collections, + issueTypes, + externalIssueTypes, + selectedIssueType, + onGetExternalIssueTypes, + onViewIssueType, +}: ProviderCardProps) { + const [apiToken, setApiToken] = useState(''); + const [showCredentials, setShowCredentials] = useState(false); + const sectionKey = type === 'jira' ? 'kanban.jira' : 'kanban.linear'; + const enabled = config.enabled; + const projectEntries = Object.entries(config.projects ?? {}); + + const isJira = type === 'jira'; + const jiraConfig = isJira ? (config as JiraConfig) : null; + const providerLabel = isJira ? 'Jira Cloud' : 'Linear'; + + const isConnected = validationResult?.valid === true; + const projectCount = projectEntries.length; + + return ( + + + {/* Header */} + + + + + {providerLabel} + + + {projectCount > 0 && ( + + )} + + onUpdate(sectionKey, 'enabled', e.target.checked)} + size="small" + /> + } + label="Enabled" + /> + + + + {/* Summary line */} + {!showCredentials && ( + + + {isJira ? `${domain} · ${jiraConfig?.email || 'no email'}` : domain} + + + + )} + + {/* Credentials (collapsible) */} + + + {isJira ? ( + <> + onUpdate(sectionKey, 'domain', e.target.value)} + placeholder="your-org.atlassian.net" + disabled={!enabled} + helperText="Jira Cloud instance domain" + /> + onUpdate(sectionKey, 'email', e.target.value)} + placeholder="you@example.com" + disabled={!enabled} + /> + onUpdate(sectionKey, 'api_key_env', e.target.value)} + disabled={!enabled} + /> + + ) : ( + <> + onUpdate(sectionKey, 'team_id', e.target.value)} + disabled={!enabled} + /> + onUpdate(sectionKey, 'api_key_env', e.target.value)} + disabled={!enabled} + /> + + )} + + + setApiToken(e.target.value)} + placeholder={isJira ? 'Paste token to validate' : 'lin_api_xxxxx'} + disabled={!enabled} + sx={{ flexGrow: 1 }} + /> + + + + {validationResult && ( + + {validationResult.valid + ? isJira + ? `Authenticated as ${(validationResult as JiraValidationInfo).displayName}` + : `Authenticated as ${(validationResult as LinearValidationInfo).userName} in ${(validationResult as LinearValidationInfo).orgName}` + : validationResult.error} + + )} + + + + + + {/* Project list */} + + {projectEntries.length === 0 ? ( + + No projects configured. Add a project key above to start syncing. + + ) : ( + projectEntries.map(([key, project]) => ( + + )) + )} + + {/* Add project shortcut */} + + { + onUpdate(sectionKey, `projects.${key}.collection_name`, ''); + }} + /> + + + + + + ); +} + +function AddProjectInput({ disabled, onAdd }: { disabled: boolean; onAdd: (key: string) => void }) { + const [value, setValue] = useState(''); + return ( + + setValue(e.target.value.toUpperCase())} + placeholder="PROJ" + disabled={disabled} + sx={{ flex: 1 }} + /> + + + ); +} diff --git a/vscode-extension/webview-ui/components/kanban/StepEditor.tsx b/vscode-extension/webview-ui/components/kanban/StepEditor.tsx new file mode 100644 index 0000000..5629cb5 --- /dev/null +++ b/vscode-extension/webview-ui/components/kanban/StepEditor.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import type { CreateStepRequest } from '../../../src/generated/CreateStepRequest'; + +interface StepEditorProps { + step: CreateStepRequest; + index: number; + allStepNames: string[]; + onChange: (index: number, step: CreateStepRequest) => void; + onRemove: (index: number) => void; + readOnly?: boolean; +} + +const PERMISSION_MODES = [ + { value: 'default', label: 'Default (autonomous)' }, + { value: 'plan', label: 'Plan' }, + { value: 'acceptEdits', label: 'Accept Edits' }, + { value: 'delegate', label: 'Delegate' }, +]; + +const REVIEW_TYPES = [ + { value: 'none', label: 'None' }, + { value: 'plan', label: 'Plan Review' }, + { value: 'visual', label: 'Visual Review' }, + { value: 'pr', label: 'PR Review' }, +]; + +const OUTPUT_OPTIONS = ['plan', 'code', 'test', 'pr', 'ticket', 'review', 'report', 'documentation']; + +const MODE_COLORS: Record = { + acceptEdits: '#4caf50', + default: '#4caf50', + plan: '#2196f3', + delegate: '#ff9800', +}; + +export function StepEditor({ step, index, allStepNames, onChange, onRemove, readOnly }: StepEditorProps) { + const update = (patch: Partial) => { + onChange(index, { ...step, ...patch }); + }; + + const modeColor = MODE_COLORS[step.permission_mode] || MODE_COLORS.default; + + return ( + + + + Step {index + 1} + + {!readOnly && ( + onRemove(index)} sx={{ p: 0.5 }}> + + + )} + + + + update({ name: e.target.value })} + disabled={readOnly} + sx={{ flex: 1 }} + /> + update({ display_name: e.target.value || undefined })} + disabled={readOnly} + sx={{ flex: 1 }} + /> + + + update({ prompt: e.target.value })} + disabled={readOnly} + fullWidth + multiline + minRows={2} + maxRows={6} + sx={{ mb: 1, '& .MuiInputBase-input': { fontFamily: 'monospace', fontSize: '0.8rem' } }} + /> + + + + Permission Mode + + + + + Review Type + + + + + Next Step + + + + + {/* Outputs */} + + + Outputs + + + {OUTPUT_OPTIONS.map((output) => { + const selected = (step.outputs ?? []).includes(output); + return ( + { + const outputs = selected + ? (step.outputs ?? []).filter(o => o !== output) + : [...(step.outputs ?? []), output]; + update({ outputs }); + }} + sx={{ cursor: readOnly ? 'default' : 'pointer' }} + /> + ); + })} + + + + ); +} diff --git a/vscode-extension/webview-ui/components/kanban/WorkflowPreview.tsx b/vscode-extension/webview-ui/components/kanban/WorkflowPreview.tsx new file mode 100644 index 0000000..816699e --- /dev/null +++ b/vscode-extension/webview-ui/components/kanban/WorkflowPreview.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import type { StepResponse } from '../../../src/generated/StepResponse'; + +interface WorkflowPreviewProps { + steps: StepResponse[]; + compact?: boolean; +} + +const MODE_COLORS: Record = { + acceptEdits: '#4caf50', + default: '#4caf50', + plan: '#2196f3', + delegate: '#ff9800', +}; + +function buildStepChain(steps: StepResponse[]): StepResponse[] { + if (steps.length === 0) { + return []; + } + + // Find step with no incoming next_step references (first step) + const referencedNames = new Set(steps.map(s => s.next_step).filter(Boolean)); + let first = steps.find(s => !referencedNames.has(s.name)); + if (!first) { + first = steps[0]; + } + + const chain: StepResponse[] = []; + const visited = new Set(); + let current: StepResponse | undefined = first; + + while (current && !visited.has(current.name)) { + chain.push(current); + visited.add(current.name); + current = current.next_step + ? steps.find(s => s.name === current!.next_step) + : undefined; + } + + // Add any remaining steps not in chain + for (const s of steps) { + if (!visited.has(s.name)) { + chain.push(s); + } + } + + return chain; +} + +export function WorkflowPreview({ steps, compact = false }: WorkflowPreviewProps) { + const chain = buildStepChain(steps); + + if (chain.length === 0) { + return ( + + No workflow steps defined + + ); + } + + if (compact) { + return ( + + {chain.map((step, i) => ( + + {i > 0 && ( + + → + + )} + + + {step.display_name || step.name} + + + Mode: {step.permission_mode} | Review: {step.review_type} + + {step.outputs.length > 0 && ( + + Outputs: {step.outputs.join(', ')} + + )} + {step.prompt && ( + + {step.prompt.substring(0, 100)}{step.prompt.length > 100 ? '...' : ''} + + )} + + } + arrow + > + + {step.display_name || step.name} + {step.review_type !== 'none' && ' ★'} + + + + ))} + + ); + } + + // Vertical step list + return ( + + {chain.map((step, i) => ( + + + + {i + 1}. {step.display_name || step.name} + + {step.review_type !== 'none' && ( + ★ review + )} + + + + Mode: {step.permission_mode} + + {step.outputs.length > 0 && ( + + Outputs: {step.outputs.join(', ')} + + )} + + + ))} + + ); +} diff --git a/vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx b/vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx index b5059ae..5e22f32 100644 --- a/vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx +++ b/vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx @@ -10,6 +10,8 @@ import { SectionHeader } from '../SectionHeader'; import type { AgentsConfig } from '../../../src/generated/AgentsConfig'; import type { LlmToolsConfig } from '../../../src/generated/LlmToolsConfig'; +const LLM_ICON_NAMES = ['claude', 'codex', 'gemini']; + interface CodingAgentsSectionProps { agents: AgentsConfig; llm_tools: LlmToolsConfig; @@ -46,6 +48,11 @@ export function CodingAgentsSection({ detected.map((tool) => ( + : undefined + } label={`${tool.name} ${tool.version}`} size="small" color={tool.version_ok ? 'default' : 'warning'} diff --git a/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx b/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx index 670f6ef..d76aeaa 100644 --- a/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx +++ b/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx @@ -31,7 +31,7 @@ export function GitRepositoriesSection({ - Configure git provider and branch settings. For more details see the git documentation + Configure workspace git provider and branch settings. For more details see the git documentation diff --git a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx index 1208da5..329f5ec 100644 --- a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx +++ b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx @@ -1,21 +1,24 @@ -import React, { useState } from 'react'; +import React from 'react'; import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import Button from '@mui/material/Button'; -import Switch from '@mui/material/Switch'; -import FormControlLabel from '@mui/material/FormControlLabel'; import Typography from '@mui/material/Typography'; -import Alert from '@mui/material/Alert'; import Link from '@mui/material/Link'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import CircularProgress from '@mui/material/CircularProgress'; import { SectionHeader } from '../SectionHeader'; -import type { JiraValidationInfo, LinearValidationInfo } from '../../types/messages'; +import { ProviderCard } from '../kanban/ProviderCard'; +import { CollectionsSubSection } from '../kanban/CollectionsSubSection'; +import { IssueTypeDrawer } from '../kanban/IssueTypeDrawer'; +import type { + JiraValidationInfo, + LinearValidationInfo, + IssueTypeSummary, + IssueTypeResponse, + CollectionResponse, + ExternalIssueTypeSummary, +} from '../../types/messages'; import type { KanbanConfig } from '../../../src/generated/KanbanConfig'; import type { JiraConfig } from '../../../src/generated/JiraConfig'; import type { LinearConfig } from '../../../src/generated/LinearConfig'; -import type { ProjectSyncConfig } from '../../../src/generated/ProjectSyncConfig'; +import type { CreateIssueTypeRequest } from '../../../src/generated/CreateIssueTypeRequest'; +import type { UpdateIssueTypeRequest } from '../../../src/generated/UpdateIssueTypeRequest'; interface KanbanProvidersSectionProps { kanban: KanbanConfig; @@ -26,25 +29,31 @@ interface KanbanProvidersSectionProps { linearResult: LinearValidationInfo | null; validatingJira: boolean; validatingLinear: boolean; + apiReachable: boolean; + issueTypes: IssueTypeSummary[]; + issueTypesLoading: boolean; + issueTypeError: string | null; + collections: CollectionResponse[]; + collectionsLoading: boolean; + collectionsError: string | null; + externalIssueTypes: Map; + selectedIssueType: IssueTypeResponse | null; + drawerOpen: boolean; + drawerMode: 'view' | 'edit' | 'create'; + onGetIssueTypes: () => void; + onGetIssueType: (key: string) => void; + onGetCollections: () => void; + onActivateCollection: (name: string) => void; + onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + onCreateIssueType: (request: CreateIssueTypeRequest) => void; + onUpdateIssueType: (key: string, request: UpdateIssueTypeRequest) => void; + onDeleteIssueType: (key: string) => void; + onOpenDrawer: (mode: 'view' | 'edit' | 'create', issueType?: IssueTypeResponse) => void; + onCloseDrawer: () => void; } -/** Extract first entry from a domain-keyed map */ -function firstEntry(map: { [key in string]?: T }): [string, T | undefined] { - const keys = Object.keys(map); - if (keys.length === 0) { - return ['', undefined]; - } - return [keys[0], map[keys[0]]]; -} - -/** Extract first project from projects sub-map */ -function firstProject(projects: { [key in string]?: ProjectSyncConfig }): [string, ProjectSyncConfig | undefined] { - const keys = Object.keys(projects); - if (keys.length === 0) { - return ['', undefined]; - } - return [keys[0], projects[keys[0]]]; -} +const DEFAULT_JIRA: JiraConfig = { enabled: false, api_key_env: 'OPERATOR_JIRA_API_KEY', email: '', projects: {} }; +const DEFAULT_LINEAR: LinearConfig = { enabled: false, api_key_env: 'OPERATOR_LINEAR_API_KEY', projects: {} }; export function KanbanProvidersSection({ kanban, @@ -55,278 +64,159 @@ export function KanbanProvidersSection({ linearResult, validatingJira, validatingLinear, + apiReachable, + issueTypes, + issueTypesLoading: _issueTypesLoading, + issueTypeError: _issueTypeError, + collections, + collectionsLoading, + collectionsError, + externalIssueTypes, + selectedIssueType, + drawerOpen, + drawerMode, + onGetIssueTypes: _onGetIssueTypes, + onGetIssueType, + onGetCollections, + onActivateCollection, + onGetExternalIssueTypes, + onCreateIssueType, + onUpdateIssueType, + onDeleteIssueType, + onOpenDrawer, + onCloseDrawer, }: KanbanProvidersSectionProps) { - const [jiraDomain, jiraWs] = firstEntry(kanban.jira); - const [jiraProjectKey, jiraProject] = firstProject(jiraWs?.projects ?? {}); - const jiraEnabled = jiraWs?.enabled ?? false; - const jiraEmail = jiraWs?.email ?? ''; - const jiraApiKeyEnv = jiraWs?.api_key_env ?? 'OPERATOR_JIRA_API_KEY'; - - const [linearTeamId, linearWs] = firstEntry(kanban.linear); - const [, linearProject] = firstProject(linearWs?.projects ?? {}); - const linearEnabled = linearWs?.enabled ?? false; - const linearApiKeyEnv = linearWs?.api_key_env ?? 'OPERATOR_LINEAR_API_KEY'; - - const [jiraApiToken, setJiraApiToken] = useState(''); - const [linearApiKey, setLinearApiKey] = useState(''); + // Iterate all Jira domains + const jiraEntries = Object.entries(kanban.jira ?? {}); + const hasJira = jiraEntries.length > 0; + const defaultJiraDomain = 'your-org.atlassian.net'; + + // Iterate all Linear workspaces + const linearEntries = Object.entries(kanban.linear ?? {}); + const hasLinear = linearEntries.length > 0; + const defaultLinearTeam = 'default-team'; + + const handleViewIssueType = (key: string) => { + onGetIssueType(key); + // The selectedIssueType will be set via message handler + // We need to find it in the current list for immediate open + onOpenDrawer('view'); + }; return ( - Configure kanban board integrations for ticket management. For more details see the kanban documentation + Configure kanban board integrations for ticket management. For more details see the{' '} + kanban documentation - {/* Jira Cloud */} - - - - - Jira Cloud - - - onUpdate('kanban.jira', 'enabled', e.target.checked) - } - size="small" - /> - } - label="Enabled" - /> - - - - onUpdate('kanban.jira', 'domain', e.target.value)} - placeholder="your-org.atlassian.net" - disabled={!jiraEnabled} - helperText="Your Jira Cloud instance domain (e.g. your-org.atlassian.net)" - /> - - onUpdate('kanban.jira', 'email', e.target.value)} - placeholder="you@example.com" - disabled={!jiraEnabled} - helperText="Email address associated with your Jira account" - /> - - onUpdate('kanban.jira', 'api_key_env', e.target.value)} - placeholder="OPERATOR_JIRA_API_KEY" - disabled={!jiraEnabled} - helperText="Name of the environment variable containing your Jira API token" - /> - - onUpdate('kanban.jira', 'project_key', e.target.value)} - placeholder="PROJ" - disabled={!jiraEnabled} - helperText="Jira project key to sync issues from (e.g. PROJ)" - /> - - { - const statuses = e.target.value - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - onUpdate('kanban.jira', 'sync_statuses', statuses); - }} - placeholder="To Do, In Progress" - disabled={!jiraEnabled} - helperText="Workflow statuses to sync (comma-separated, e.g., To Do, In Progress)" - /> - - onUpdate('kanban.jira', 'collection_name', e.target.value)} - placeholder="dev_kanban" - disabled={!jiraEnabled} - helperText="IssueType collection this project maps to" - /> - - - - Validate Connection - - - setJiraApiToken(e.target.value)} - placeholder="Paste token to validate" - disabled={!jiraEnabled} - sx={{ flexGrow: 1 }} - helperText="Paste your Jira API token to test the connection" - /> - - - - - {jiraResult && ( - - {jiraResult.valid - ? `Authenticated as ${jiraResult.displayName} (${jiraResult.accountId})` - : jiraResult.error} - - )} - - - - - {/* Linear */} - - - - - Linear - - - onUpdate('kanban.linear', 'enabled', e.target.checked) - } - size="small" - /> - } - label="Enabled" - /> - - - - onUpdate('kanban.linear', 'team_id', e.target.value)} - placeholder="Team identifier" - disabled={!linearEnabled} - helperText="Linear team identifier to sync issues from" - /> - - onUpdate('kanban.linear', 'api_key_env', e.target.value)} - placeholder="OPERATOR_LINEAR_API_KEY" - disabled={!linearEnabled} - helperText="Name of the environment variable containing your Linear API key" - /> - - { - const statuses = e.target.value - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - onUpdate('kanban.linear', 'sync_statuses', statuses); - }} - placeholder="To Do, In Progress" - disabled={!linearEnabled} - helperText="Workflow statuses to sync (comma-separated, e.g., To Do, In Progress)" - /> - - onUpdate('kanban.linear', 'collection_name', e.target.value)} - placeholder="dev_kanban" - disabled={!linearEnabled} - helperText="IssueType collection this project maps to" - /> - - - - Validate Connection - - - setLinearApiKey(e.target.value)} - placeholder="lin_api_xxxxx" - disabled={!linearEnabled} - sx={{ flexGrow: 1 }} - helperText="Paste your Linear API key to test the connection" - /> - - - - - {linearResult && ( - - {linearResult.valid - ? `Authenticated as ${linearResult.userName} in ${linearResult.orgName}` - : linearResult.error} - - )} - - - + {/* Jira providers */} + {hasJira ? ( + jiraEntries.map(([domain, config]) => ( + + )) + ) : ( + + )} + + {/* Linear providers */} + {hasLinear ? ( + linearEntries.map(([teamId, config]) => ( + + )) + ) : ( + + )} + + {/* Collections & Issue Types (shown when API is reachable) */} + {apiReachable && ( + onOpenDrawer('create')} + /> + )} + + {/* Issue Type Drawer */} + ); } diff --git a/vscode-extension/webview-ui/index.tsx b/vscode-extension/webview-ui/index.tsx index f33ce58..0060db8 100644 --- a/vscode-extension/webview-ui/index.tsx +++ b/vscode-extension/webview-ui/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; +import '../images/icons/dist/operator-icons.css'; import { App } from './App'; const container = document.getElementById('root'); diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index 0746b2b..e79a2c2 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -81,6 +81,14 @@ const DEFAULT_CONFIG: Config = { webhook_port: 7007, connect_timeout_ms: BigInt(5000), }, + cmux: { + binary_path: '/Applications/cmux.app/Contents/Resources/bin/cmux', + require_in_cmux: false, + placement: 'auto' + }, + zellij: { + require_in_zellij: false + } }, llm_tools: { detected: [], diff --git a/vscode-extension/webview-ui/types/messages.ts b/vscode-extension/webview-ui/types/messages.ts index 25c5258..9c02095 100644 --- a/vscode-extension/webview-ui/types/messages.ts +++ b/vscode-extension/webview-ui/types/messages.ts @@ -1,4 +1,11 @@ import type { Config } from '../../src/generated/Config'; +import type { IssueTypeSummary } from '../../src/generated/IssueTypeSummary'; +import type { IssueTypeResponse } from '../../src/generated/IssueTypeResponse'; +import type { CollectionResponse } from '../../src/generated/CollectionResponse'; +import type { ExternalIssueTypeSummary } from '../../src/generated/ExternalIssueTypeSummary'; + +// Re-export generated types for consumers +export type { IssueTypeSummary, IssueTypeResponse, CollectionResponse, ExternalIssueTypeSummary }; /** Wrapper that pairs the generated Config with extension metadata */ export interface WebviewConfig { @@ -43,7 +50,15 @@ export type WebviewToExtensionMessage = | { type: 'checkApiHealth' } | { type: 'getProjects' } | { type: 'assessProject'; projectName: string } - | { type: 'openProjectFolder'; projectPath: string }; + | { type: 'openProjectFolder'; projectPath: string } + | { type: 'getIssueTypes' } + | { type: 'getIssueType'; key: string } + | { type: 'getCollections' } + | { type: 'activateCollection'; name: string } + | { type: 'getExternalIssueTypes'; provider: string; domain: string; projectKey: string } + | { type: 'createIssueType'; request: import('../../src/generated/CreateIssueTypeRequest').CreateIssueTypeRequest } + | { type: 'updateIssueType'; key: string; request: import('../../src/generated/UpdateIssueTypeRequest').UpdateIssueTypeRequest } + | { type: 'deleteIssueType'; key: string }; /** Messages from the extension host to the webview */ export type ExtensionToWebviewMessage = @@ -58,7 +73,18 @@ export type ExtensionToWebviewMessage = | { type: 'projectsLoaded'; projects: ProjectSummary[] } | { type: 'projectsError'; error: string } | { type: 'assessTicketCreated'; ticketId: string; projectName: string } - | { type: 'assessTicketError'; error: string; projectName: string }; + | { type: 'assessTicketError'; error: string; projectName: string } + | { type: 'issueTypesLoaded'; issueTypes: IssueTypeSummary[] } + | { type: 'issueTypeLoaded'; issueType: IssueTypeResponse } + | { type: 'issueTypeError'; error: string } + | { type: 'collectionsLoaded'; collections: CollectionResponse[] } + | { type: 'collectionActivated'; name: string } + | { type: 'collectionsError'; error: string } + | { type: 'externalIssueTypesLoaded'; provider: string; projectKey: string; types: ExternalIssueTypeSummary[] } + | { type: 'externalIssueTypesError'; provider: string; projectKey: string; error: string } + | { type: 'issueTypeCreated'; issueType: IssueTypeResponse } + | { type: 'issueTypeUpdated'; issueType: IssueTypeResponse } + | { type: 'issueTypeDeleted'; key: string }; export interface JiraValidationInfo { valid: boolean; From 06f6990afc3412509134b5a8ba0a7ca5003d622e Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 22 Mar 2026 10:27:37 -0600 Subject: [PATCH 2/6] clippy fixes, even further unreadable_literal default_trait_access Co-Authored-By: Claude Opus 4.5 --- src/agents/activity.rs | 18 ++-- src/agents/agent_switcher.rs | 11 +-- src/agents/launcher/llm_command.rs | 50 ++++------- src/agents/launcher/tests.rs | 83 +++++++---------- src/agents/monitor.rs | 4 +- src/agents/tmux.rs | 2 +- src/api/providers/repo/mod.rs | 6 +- src/app/tests.rs | 33 +++---- src/backstage/analyzer.rs | 2 +- src/backstage/server.rs | 2 +- src/backstage/taxonomy.rs | 8 +- src/docs_gen/startup.rs | 5 +- src/issuetypes/loader.rs | 8 +- src/issuetypes/mod.rs | 2 +- src/issuetypes/schema.rs | 2 +- src/logging.rs | 2 +- src/notifications/mod.rs | 4 +- src/notifications/service.rs | 2 +- src/pr_config/mod.rs | 4 +- src/projects.rs | 2 +- src/queue/creator.rs | 18 ++-- src/queue/mod.rs | 5 +- src/queue/ticket.rs | 99 ++++++++++----------- src/rest/openapi.rs | 5 +- src/rest/routes/collections.rs | 3 +- src/rest/routes/queue.rs | 4 +- src/services/kanban_sync.rs | 8 +- src/startup/mod.rs | 2 +- src/state.rs | 4 +- src/steps/session.rs | 4 +- src/ui/collection_dialog.rs | 8 +- src/ui/dashboard.rs | 3 +- src/ui/dialogs/confirm.rs | 6 +- src/ui/dialogs/session_recovery.rs | 2 +- src/ui/panels.rs | 6 +- tests/feature_parity_test.rs | 14 ++- tests/git_integration.rs | 138 ++++++++++++----------------- tests/kanban_integration.rs | 43 ++++----- tests/launch_common/mod.rs | 14 ++- tests/launch_integration.rs | 84 ++++++++---------- tests/launch_integration_cmux.rs | 41 ++++----- tests/launch_integration_vscode.rs | 33 +++---- tests/launch_integration_zellij.rs | 42 ++++----- tests/opr8r_integration.rs | 2 +- 44 files changed, 365 insertions(+), 473 deletions(-) diff --git a/src/agents/activity.rs b/src/agents/activity.rs index 8dbf0e3..958b56f 100644 --- a/src/agents/activity.rs +++ b/src/agents/activity.rs @@ -537,8 +537,7 @@ mod tests { .unwrap(); client.set_screen_content(&ws_id, "Done with task\n> "); - let detector = - CmuxActivityDetector::new(client.clone(), create_idle_detector_with_patterns()); + let detector = CmuxActivityDetector::new(client, create_idle_detector_with_patterns()); detector.register_workspace("session-1", &ws_id); assert!(detector.is_idle("session-1").unwrap()); @@ -552,8 +551,7 @@ mod tests { .unwrap(); client.set_screen_content(&ws_id, "⠋ Thinking about your request...\n"); - let detector = - CmuxActivityDetector::new(client.clone(), create_idle_detector_with_patterns()); + let detector = CmuxActivityDetector::new(client, create_idle_detector_with_patterns()); detector.register_workspace("session-1", &ws_id); assert!(!detector.is_idle("session-1").unwrap()); @@ -592,8 +590,7 @@ mod tests { .unwrap(); client.set_screen_content(&ws_id, "content\n> "); - let detector = - CmuxActivityDetector::new(client.clone(), create_idle_detector_with_patterns()); + let detector = CmuxActivityDetector::new(client, create_idle_detector_with_patterns()); detector.register_workspace("session-1", &ws_id); // Prime the hash @@ -616,8 +613,7 @@ mod tests { client.create_tab("agent-tab", "/tmp").unwrap(); client.set_screen_content("agent-tab", "Done with task\n> "); - let detector = - ZellijActivityDetector::new(client.clone(), create_idle_detector_with_patterns()); + let detector = ZellijActivityDetector::new(client, create_idle_detector_with_patterns()); detector.register_tab("session-1", "agent-tab"); assert!(detector.is_idle("session-1").unwrap()); @@ -629,8 +625,7 @@ mod tests { client.create_tab("agent-tab", "/tmp").unwrap(); client.set_screen_content("agent-tab", "⠋ Thinking about your request...\n"); - let detector = - ZellijActivityDetector::new(client.clone(), create_idle_detector_with_patterns()); + let detector = ZellijActivityDetector::new(client, create_idle_detector_with_patterns()); detector.register_tab("session-1", "agent-tab"); assert!(!detector.is_idle("session-1").unwrap()); @@ -665,8 +660,7 @@ mod tests { client.create_tab("agent-tab", "/tmp").unwrap(); client.set_screen_content("agent-tab", "content\n> "); - let detector = - ZellijActivityDetector::new(client.clone(), create_idle_detector_with_patterns()); + let detector = ZellijActivityDetector::new(client, create_idle_detector_with_patterns()); detector.register_tab("session-1", "agent-tab"); // Prime the hash diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index ba438ba..445ce58 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -307,7 +307,7 @@ mod tests { permissions: None, cli_args: None, permission_mode: PermissionMode::Default, - agent: agent.map(|s| s.to_string()), + agent: agent.map(std::string::ToString::to_string), json_schema: None, json_schema_file: None, artifact_patterns: vec![], @@ -431,8 +431,7 @@ mod tests { let keys = mock.get_session_keys_sent("op-test").unwrap(); assert!( keys[0].contains("/exit"), - "Should send /exit for claude, got: {:?}", - keys + "Should send /exit for claude, got: {keys:?}" ); } @@ -448,8 +447,7 @@ mod tests { let keys = mock.get_session_keys_sent("op-test").unwrap(); assert!( keys[0].contains("/quit"), - "Should send /quit for gemini, got: {:?}", - keys + "Should send /quit for gemini, got: {keys:?}" ); } @@ -466,8 +464,7 @@ mod tests { // Codex exit is Ctrl+C (0x03), sent without Enter assert!( keys[0].contains('\x03'), - "Should send Ctrl+C for codex, got: {:?}", - keys + "Should send Ctrl+C for codex, got: {keys:?}" ); } } diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index f4cf3d7..a758d5a 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -311,8 +311,7 @@ mod tests { assert!( result.contains("claude --dangerously-skip-permissions --model"), - "YOLO flag should be inserted after tool name, got: {}", - result + "YOLO flag should be inserted after tool name, got: {result}" ); } @@ -330,8 +329,7 @@ mod tests { assert!( result.contains("--dangerously-skip-permissions --no-confirm"), - "Multiple YOLO flags should be joined with spaces, got: {}", - result + "Multiple YOLO flags should be joined with spaces, got: {result}" ); } @@ -407,8 +405,7 @@ mod tests { let cmd = result.unwrap(); assert!( cmd.contains("-v /home/user/project:/workspace:rw"), - "Should mount project path with :rw, got: {}", - cmd + "Should mount project path with :rw, got: {cmd}" ); } @@ -423,8 +420,7 @@ mod tests { let cmd = result.unwrap(); assert!( cmd.contains("-w /workspace"), - "Should set working dir to mount path, got: {}", - cmd + "Should set working dir to mount path, got: {cmd}" ); } @@ -441,13 +437,11 @@ mod tests { let cmd = result.unwrap(); assert!( cmd.contains("-e ANTHROPIC_API_KEY"), - "Should pass first env var, got: {}", - cmd + "Should pass first env var, got: {cmd}" ); assert!( cmd.contains("-e HOME=/root"), - "Should pass second env var, got: {}", - cmd + "Should pass second env var, got: {cmd}" ); } @@ -476,8 +470,7 @@ mod tests { let err = result.unwrap_err().to_string(); assert!( err.contains("no image is configured"), - "Error should mention missing image, got: {}", - err + "Error should mention missing image, got: {err}" ); } @@ -492,8 +485,7 @@ mod tests { let cmd = result.unwrap(); assert!( cmd.contains("sh -c claude --model sonnet"), - "Should wrap inner command with sh -c, got: {}", - cmd + "Should wrap inner command with sh -c, got: {cmd}" ); } @@ -572,8 +564,7 @@ mod tests { let err = result.unwrap_err().to_string(); assert!( err.contains("not detected"), - "Error should mention tool not detected, got: {}", - err + "Error should mention tool not detected, got: {err}" ); } @@ -596,18 +587,15 @@ mod tests { let cmd = result.unwrap(); assert!( cmd.contains("--model opus"), - "Should interpolate model, got: {}", - cmd + "Should interpolate model, got: {cmd}" ); assert!( cmd.contains("--session-id sess-abc"), - "Should interpolate session_id, got: {}", - cmd + "Should interpolate session_id, got: {cmd}" ); assert!( cmd.contains("/tmp/prompt.md"), - "Should interpolate prompt_file, got: {}", - cmd + "Should interpolate prompt_file, got: {cmd}" ); } @@ -631,8 +619,7 @@ mod tests { // When no ticket, config_flags should be empty, so command starts with "claude --model" assert!( cmd.starts_with("claude --model"), - "Should have empty config_flags when no ticket, got: {}", - cmd + "Should have empty config_flags when no ticket, got: {cmd}" ); } @@ -656,8 +643,7 @@ mod tests { // Model flag should have trailing space per the code assert!( cmd.contains("--model haiku "), - "Model flag should have trailing space, got: {}", - cmd + "Model flag should have trailing space, got: {cmd}" ); } @@ -988,10 +974,10 @@ mod tests { assert!(path_str.contains("schema.json")); assert!(path_str.contains("sessions")); - assert!(!path_str.contains("{")); // No JSON content + assert!(!path_str.contains('{')); // No JSON content } - /// Test that json_schema_file path existence check works + /// Test that `json_schema_file` path existence check works #[test] #[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"] fn test_schema_file_path_exists_check() { @@ -1038,8 +1024,8 @@ mod tests { // Verify the path is simple and safe for shell let path_str = schema_path.to_string_lossy().to_string(); assert!(!path_str.contains('\n')); - assert!(!path_str.contains("\"")); - assert!(!path_str.contains("'")); + assert!(!path_str.contains('"')); + assert!(!path_str.contains('\'')); // Verify content is preserved let content = std::fs::read_to_string(&schema_path).unwrap(); diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 9bcd8ea..136bf07 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -222,7 +222,7 @@ fn test_kill_session() { mock.add_session("op-TASK-123", "/tmp"); - let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let launcher = Launcher::with_tmux_client(&config, mock).unwrap(); assert!(launcher.session_alive("op-TASK-123")); launcher.kill_session("op-TASK-123").unwrap(); @@ -304,8 +304,8 @@ fn test_generate_session_uuid_is_unique() { fn make_test_ticket(project: &str) -> Ticket { Ticket { - filename: format!("20241225-1200-TASK-{}-test.md", project), - filepath: format!("/tmp/tickets/queue/20241225-1200-TASK-{}-test.md", project), + filename: format!("20241225-1200-TASK-{project}-test.md"), + filepath: format!("/tmp/tickets/queue/20241225-1200-TASK-{project}-test.md"), timestamp: "20241225-1200".to_string(), ticket_type: "TASK".to_string(), project: project.to_string(), @@ -455,9 +455,8 @@ async fn test_launch_command_includes_cd_to_project() { .to_string_lossy() .to_string(); assert!( - script_content.contains(&format!("cd '{}'", expected_path)), - "Command file should include cd to project path, got: {}", - script_content + script_content.contains(&format!("cd '{expected_path}'")), + "Command file should include cd to project path, got: {script_content}" ); } @@ -520,7 +519,7 @@ fn test_launch_in_tmux_session_uses_prefix() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = mock; let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -543,8 +542,7 @@ fn test_launch_in_tmux_session_uses_prefix() { let session_name = result.unwrap(); assert!( session_name.starts_with("op-"), - "Session should use op- prefix, got: {}", - session_name + "Session should use op- prefix, got: {session_name}" ); } @@ -580,8 +578,7 @@ fn test_launch_in_tmux_existing_session_returns_error() { let err = result.unwrap_err().to_string(); assert!( err.contains("already exists"), - "Error should mention session exists, got: {}", - err + "Error should mention session exists, got: {err}" ); } @@ -618,8 +615,7 @@ fn test_launch_in_tmux_sends_cd_command() { // Command should be a bash script execution assert!( sent_cmd.starts_with("bash "), - "Command should be a bash script execution, got: {}", - sent_cmd + "Command should be a bash script execution, got: {sent_cmd}" ); // Read the script content and verify it contains cd @@ -627,8 +623,7 @@ fn test_launch_in_tmux_sends_cd_command() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("cd "), - "Command file should contain cd, got: {}", - script_content + "Command file should contain cd, got: {script_content}" ); } @@ -666,13 +661,11 @@ fn test_launch_in_tmux_sends_llm_command() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("claude"), - "Command file should contain claude, got: {}", - script_content + "Command file should contain claude, got: {script_content}" ); assert!( script_content.contains("--session-id"), - "Command file should contain --session-id, got: {}", - script_content + "Command file should contain --session-id, got: {script_content}" ); } @@ -713,8 +706,7 @@ fn test_launch_in_tmux_yolo_mode_applies_flags() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("--dangerously-skip-permissions"), - "Command file should contain YOLO flag, got: {}", - script_content + "Command file should contain YOLO flag, got: {script_content}" ); } @@ -755,8 +747,7 @@ fn test_launch_in_tmux_yolo_mode_disabled_no_flags() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( !script_content.contains("--dangerously-skip-permissions"), - "Command file should NOT contain YOLO flag when disabled, got: {}", - script_content + "Command file should NOT contain YOLO flag when disabled, got: {script_content}" ); } @@ -797,8 +788,7 @@ fn test_launch_in_tmux_docker_mode_wraps() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("docker run"), - "Command file should contain docker run, got: {}", - script_content + "Command file should contain docker run, got: {script_content}" ); } @@ -840,13 +830,11 @@ fn test_launch_in_tmux_both_modes() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("docker run"), - "Command file should contain docker run, got: {}", - script_content + "Command file should contain docker run, got: {script_content}" ); assert!( script_content.contains("--dangerously-skip-permissions"), - "Command file should contain YOLO flag, got: {}", - script_content + "Command file should contain YOLO flag, got: {script_content}" ); } @@ -905,13 +893,11 @@ fn test_launch_in_tmux_uses_provider_from_options() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("gemini"), - "Command file should use gemini tool, got: {}", - script_content + "Command file should use gemini tool, got: {script_content}" ); assert!( script_content.contains("--model pro"), - "Command file should use pro model, got: {}", - script_content + "Command file should use pro model, got: {script_content}" ); } @@ -920,7 +906,7 @@ fn test_launch_in_tmux_writes_prompt_file() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = mock; let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -949,7 +935,7 @@ fn test_launch_in_tmux_writes_prompt_file() { .join("prompts"); let prompt_files: Vec<_> = std::fs::read_dir(&prompts_dir) .unwrap() - .filter_map(|e| e.ok()) + .filter_map(std::result::Result::ok) .collect(); assert!( !prompt_files.is_empty(), @@ -962,7 +948,7 @@ fn test_launch_in_tmux_tmux_not_installed() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::not_installed()); - let tmux: Arc = mock.clone(); + let tmux: Arc = mock; let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -985,8 +971,7 @@ fn test_launch_in_tmux_tmux_not_installed() { let err = result.unwrap_err().to_string(); assert!( err.contains("tmux is not installed"), - "Error should mention tmux not installed, got: {}", - err + "Error should mention tmux not installed, got: {err}" ); } @@ -999,7 +984,7 @@ fn test_relaunch_fresh_start_new_uuid() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = mock; let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1067,8 +1052,7 @@ fn test_relaunch_inherits_yolo_mode() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("--dangerously-skip-permissions"), - "Relaunch command file should apply YOLO flags, got: {}", - script_content + "Relaunch command file should apply YOLO flags, got: {script_content}" ); } @@ -1113,8 +1097,7 @@ fn test_relaunch_inherits_docker_mode() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("docker run"), - "Relaunch command file should apply Docker wrapping, got: {}", - script_content + "Relaunch command file should apply Docker wrapping, got: {script_content}" ); } @@ -1151,8 +1134,7 @@ fn test_relaunch_existing_session_errors() { let err = result.unwrap_err().to_string(); assert!( err.contains("already exists"), - "Relaunch should fail if session exists, got: {}", - err + "Relaunch should fail if session exists, got: {err}" ); } @@ -1179,7 +1161,7 @@ fn test_relaunch_with_resume_adds_flag() { .join("prompts"); std::fs::create_dir_all(&prompts_dir).unwrap(); std::fs::write( - prompts_dir.join(format!("{}.txt", resume_uuid)), + prompts_dir.join(format!("{resume_uuid}.txt")), "Previous prompt", ) .unwrap(); @@ -1209,13 +1191,11 @@ fn test_relaunch_with_resume_adds_flag() { read_command_file_content(sent_cmd).expect("Should be able to read command file content"); assert!( script_content.contains("--resume"), - "Resume mode command file should add --resume flag, got: {}", - script_content + "Resume mode command file should add --resume flag, got: {script_content}" ); assert!( script_content.contains(resume_uuid), - "Resume should use the provided session ID, got: {}", - script_content + "Resume should use the provided session ID, got: {script_content}" ); } @@ -1261,7 +1241,6 @@ fn test_relaunch_missing_prompt_fresh_start() { // Should NOT have resume flag since prompt file doesn't exist assert!( !script_content.contains("--resume"), - "Should fall back to fresh start when prompt file missing, got: {}", - script_content + "Should fall back to fresh start when prompt file missing, got: {script_content}" ); } diff --git a/src/agents/monitor.rs b/src/agents/monitor.rs index 32d7d1f..3e0d043 100644 --- a/src/agents/monitor.rs +++ b/src/agents/monitor.rs @@ -957,7 +957,7 @@ mod tests { assert_eq!(orphans[0].session_name, "op-ORPHAN-777"); } - /// Helper: write a hook signal file to trigger idle detection in check_health + /// Helper: write a hook signal file to trigger idle detection in `check_health` fn write_hook_signal(agent_id: &str) -> PathBuf { use crate::agents::hooks::HookSignal; @@ -968,7 +968,7 @@ mod tests { timestamp: 1234567890, session_id: agent_id.to_string(), }; - let signal_path = signal_dir.join(format!("{}.signal", agent_id)); + let signal_path = signal_dir.join(format!("{agent_id}.signal")); std::fs::write(&signal_path, serde_json::to_string(&signal).unwrap()).unwrap(); signal_path } diff --git a/src/agents/tmux.rs b/src/agents/tmux.rs index 727086d..3d531e3 100644 --- a/src/agents/tmux.rs +++ b/src/agents/tmux.rs @@ -1938,7 +1938,7 @@ mod tests { // Create a 3.5KB command - exceeds both send-keys limit (~2KB) and typical CLI arg limits // This verifies that the buffer method can handle content that would fail with CLI args let long_content = "a".repeat(3500); - let long_cmd = format!("echo '{}'", long_content); + let long_cmd = format!("echo '{long_content}'"); assert!(long_cmd.len() > 3500, "Command should be >3.5KB"); client diff --git a/src/api/providers/repo/mod.rs b/src/api/providers/repo/mod.rs index 1a53c4d..c090756 100644 --- a/src/api/providers/repo/mod.rs +++ b/src/api/providers/repo/mod.rs @@ -184,7 +184,7 @@ mod tests { assert!(!draft_pr.is_ready_to_merge()); // Changes requested not ready - let mut changes_pr = pr.clone(); + let mut changes_pr = pr; changes_pr.review_status = "changes_requested".to_string(); assert!(!changes_pr.is_ready_to_merge()); } @@ -196,11 +196,11 @@ mod tests { number: 123, state: "open".to_string(), title: "Test".to_string(), - html_url: "".to_string(), + html_url: String::new(), draft: false, merged: false, mergeable: None, - head_sha: "".to_string(), + head_sha: String::new(), review_status: "pending".to_string(), checks_passed: None, }; diff --git a/src/app/tests.rs b/src/app/tests.rs index 91281c5..df6496d 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -172,8 +172,11 @@ mod state_transitions { fn test_ctrl_c_timeout_clears_confirmation() { let mut exit_confirmation_mode = true; // Set a time in the past (simulating timeout) - let mut exit_confirmation_time = - Some(std::time::Instant::now() - std::time::Duration::from_secs(2)); + let mut exit_confirmation_time = Some( + std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(2)) + .unwrap(), + ); // Simulate the timeout check logic from run() if exit_confirmation_mode { @@ -348,13 +351,13 @@ mod launch_validation { let config = make_test_config(&temp_dir); // Create a ticket file in the queue - let ticket_content = r#"--- + let ticket_content = r"--- priority: P2-medium --- # Test ticket Test content -"#; +"; let ticket_filename = "20241225-1200-TASK-test-project-test.md"; let ticket_path = config.tickets_path().join("queue").join(ticket_filename); std::fs::write(&ticket_path, ticket_content).unwrap(); @@ -465,7 +468,7 @@ mod review_signals { // Test the condition check without full App let review_state: Option<&str> = Some("pending_plan"); - let can_approve = matches!(review_state, Some("pending_plan") | Some("pending_visual")); + let can_approve = matches!(review_state, Some("pending_plan" | "pending_visual")); assert!(can_approve); } @@ -475,7 +478,7 @@ mod review_signals { // Symmetric test for the pending_visual match arm let review_state: Option<&str> = Some("pending_visual"); - let can_approve = matches!(review_state, Some("pending_plan") | Some("pending_visual")); + let can_approve = matches!(review_state, Some("pending_plan" | "pending_visual")); assert!(can_approve, "pending_visual should also be approvable"); } @@ -484,7 +487,7 @@ mod review_signals { fn test_review_approval_blocked_for_other_states() { let review_state: Option<&str> = Some("running"); - let can_approve = matches!(review_state, Some("pending_plan") | Some("pending_visual")); + let can_approve = matches!(review_state, Some("pending_plan" | "pending_visual")); assert!(!can_approve); } @@ -494,7 +497,7 @@ mod review_signals { // Mirrors approval tests but for rejection path — same guard logic applies let review_state: Option<&str> = Some("running"); - let can_reject = matches!(review_state, Some("pending_plan") | Some("pending_visual")); + let can_reject = matches!(review_state, Some("pending_plan" | "pending_visual")); assert!( !can_reject, @@ -503,7 +506,7 @@ mod review_signals { // Also verify None is blocked let review_state: Option<&str> = None; - let can_reject = matches!(review_state, Some("pending_plan") | Some("pending_visual")); + let can_reject = matches!(review_state, Some("pending_plan" | "pending_visual")); assert!( !can_reject, "Rejection should be blocked when no review state" @@ -511,11 +514,11 @@ mod review_signals { // And verify pending states ARE rejectable let review_state: Option<&str> = Some("pending_plan"); - let can_reject = matches!(review_state, Some("pending_plan") | Some("pending_visual")); + let can_reject = matches!(review_state, Some("pending_plan" | "pending_visual")); assert!(can_reject, "pending_plan should be rejectable"); let review_state: Option<&str> = Some("pending_visual"); - let can_reject = matches!(review_state, Some("pending_plan") | Some("pending_visual")); + let can_reject = matches!(review_state, Some("pending_plan" | "pending_visual")); assert!(can_reject, "pending_visual should be rejectable"); } @@ -523,7 +526,7 @@ mod review_signals { fn test_review_approval_blocked_for_none() { let review_state: Option<&str> = None; - let can_approve = matches!(review_state, Some("pending_plan") | Some("pending_visual")); + let can_approve = matches!(review_state, Some("pending_plan" | "pending_visual")); assert!(!can_approve); } @@ -531,7 +534,7 @@ mod review_signals { #[test] fn test_review_signal_file_path() { let session_name = "op-TASK-123"; - let signal_file = format!("/tmp/operator-detach-{}.signal", session_name); + let signal_file = format!("/tmp/operator-detach-{session_name}.signal"); assert_eq!(signal_file, "/tmp/operator-detach-op-TASK-123.signal"); } @@ -585,14 +588,14 @@ mod return_to_queue { let config = make_test_config(&temp_dir); // Create a ticket in in-progress - let ticket_content = r#"--- + let ticket_content = r"--- priority: P2-medium status: in-progress --- # Test ticket Test content -"#; +"; let ticket_filename = "20241225-1200-TASK-test-project-test.md"; let in_progress_path = config .tickets_path() diff --git a/src/backstage/analyzer.rs b/src/backstage/analyzer.rs index a48bfe8..b845294 100644 --- a/src/backstage/analyzer.rs +++ b/src/backstage/analyzer.rs @@ -652,7 +652,7 @@ mod tests { evidence: vec![Evidence { evidence_type: EvidenceType::Dependency, file_path: Some("Cargo.toml".to_string()), - pattern: Some(r#"axum = "#.to_string()), + pattern: Some(r"axum = ".to_string()), matched_content: Some(r#"axum = "0.7""#.to_string()), line_number: Some(15), }], diff --git a/src/backstage/server.rs b/src/backstage/server.rs index 1d0bcf8..217ee48 100644 --- a/src/backstage/server.rs +++ b/src/backstage/server.rs @@ -849,7 +849,7 @@ mod tests { #[test] fn test_bun_version_display() { let v = BunVersion::parse("1.2.3").unwrap(); - assert_eq!(format!("{}", v), "1.2.3"); + assert_eq!(format!("{v}"), "1.2.3"); } // ==================== ServerStatus Tests ==================== diff --git a/src/backstage/taxonomy.rs b/src/backstage/taxonomy.rs index bd20179..d297880 100644 --- a/src/backstage/taxonomy.rs +++ b/src/backstage/taxonomy.rs @@ -346,8 +346,7 @@ mod tests { let kinds = t.kinds_by_tier(*tier_enum); assert!( !kinds.is_empty(), - "{} tier should have at least one kind", - tier_enum + "{tier_enum} tier should have at least one kind" ); } } @@ -437,7 +436,7 @@ mod tests { // Verify each tier returns kinds and they all reference the correct tier for tier_enum in KindTier::all() { let kinds = t.kinds_by_tier(*tier_enum); - assert!(!kinds.is_empty(), "{} tier should have kinds", tier_enum); + assert!(!kinds.is_empty(), "{tier_enum} tier should have kinds"); for kind in &kinds { assert_eq!( kind.tier, @@ -547,8 +546,7 @@ mod tests { for i in 1..kinds.len() { assert!( kinds[i - 1].display_order() <= kinds[i].display_order(), - "Kinds in {} should be sorted by display_order", - tier_enum + "Kinds in {tier_enum} should be sorted by display_order" ); } } diff --git a/src/docs_gen/startup.rs b/src/docs_gen/startup.rs index b3b9a1c..0990a94 100644 --- a/src/docs_gen/startup.rs +++ b/src/docs_gen/startup.rs @@ -145,9 +145,8 @@ mod tests { // Check that numbered headings exist for i in 1..=SETUP_STEPS.len() { assert!( - result.contains(&format!("### {}.", i)), - "Step {} should be numbered", - i + result.contains(&format!("### {i}.")), + "Step {i} should be numbered" ); } } diff --git a/src/issuetypes/loader.rs b/src/issuetypes/loader.rs index 8106aa1..8c3459c 100644 --- a/src/issuetypes/loader.rs +++ b/src/issuetypes/loader.rs @@ -636,9 +636,9 @@ types = ["FEAT", "FIX"] IssueType::new_imported( "FEAT".to_string(), "Feature".to_string(), - "".to_string(), + String::new(), "builtin".to_string(), - "".to_string(), + String::new(), None, ), ); @@ -647,9 +647,9 @@ types = ["FEAT", "FIX"] IssueType::new_imported( "FIX".to_string(), "Fix".to_string(), - "".to_string(), + String::new(), "builtin".to_string(), - "".to_string(), + String::new(), None, ), ); diff --git a/src/issuetypes/mod.rs b/src/issuetypes/mod.rs index 95410a9..412c7ba 100644 --- a/src/issuetypes/mod.rs +++ b/src/issuetypes/mod.rs @@ -525,7 +525,7 @@ mod tests { "Story".to_string(), "A user story".to_string(), "custom".to_string(), - "".to_string(), + String::new(), None, ); diff --git a/src/issuetypes/schema.rs b/src/issuetypes/schema.rs index fbee0f6..2c55ad8 100644 --- a/src/issuetypes/schema.rs +++ b/src/issuetypes/schema.rs @@ -387,7 +387,7 @@ mod tests { #[test] fn test_invalid_glyph_empty() { let mut issue_type = create_valid_issuetype(); - issue_type.glyph = "".to_string(); + issue_type.glyph = String::new(); let result = issue_type.validate(); assert!(result.is_err()); let errors = result.unwrap_err(); diff --git a/src/logging.rs b/src/logging.rs index 5e6ebb5..d67bdc9 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -119,7 +119,7 @@ mod tests { std::fs::create_dir_all(&logs_dir).unwrap(); let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); - let log_filename = format!("operator-{}.log", timestamp); + let log_filename = format!("operator-{timestamp}.log"); let log_file_path = logs_dir.join(&log_filename); assert!(log_file_path.to_string_lossy().contains("operator-")); diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index 08374a5..0d10fd2 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -441,9 +441,7 @@ mod tests { assert_eq!( event.event_type(), expected_type, - "Event {:?} should have type '{}'", - event, - expected_type + "Event {event:?} should have type '{expected_type}'" ); } } diff --git a/src/notifications/service.rs b/src/notifications/service.rs index 3cf4aea..f7e3c80 100644 --- a/src/notifications/service.rs +++ b/src/notifications/service.rs @@ -262,7 +262,7 @@ mod tests { config.notifications.webhooks = vec![WebhookConfig { name: Some("no-url".into()), enabled: true, - url: "".into(), // Empty URL + url: String::new(), // Empty URL auth_type: None, token_env: None, username: None, diff --git a/src/pr_config/mod.rs b/src/pr_config/mod.rs index 703f1ca..556bc4e 100644 --- a/src/pr_config/mod.rs +++ b/src/pr_config/mod.rs @@ -403,7 +403,7 @@ mod tests { #[test] fn test_extract_description() { - let content = r#"--- + let content = r"--- id: FEAT-123 status: queued --- @@ -414,7 +414,7 @@ It can span multiple lines. ## Context Some context here. -"#; +"; let desc = extract_description(content); assert!(desc.contains("This is the main description")); assert!(!desc.contains("Context")); diff --git a/src/projects.rs b/src/projects.rs index 95259c5..1063b1d 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -378,7 +378,7 @@ mod tests { #[test] fn test_tool_markers_has_correct_filenames() { - let markers: std::collections::HashMap<&str, &str> = TOOL_MARKERS.iter().cloned().collect(); + let markers: std::collections::HashMap<&str, &str> = TOOL_MARKERS.iter().copied().collect(); assert_eq!(markers.get("claude"), Some(&"CLAUDE.md")); assert_eq!(markers.get("gemini"), Some(&"GEMINI.md")); assert_eq!(markers.get("codex"), Some(&"CODEX.md")); diff --git a/src/queue/creator.rs b/src/queue/creator.rs index 1d2d61c..4c553cd 100644 --- a/src/queue/creator.rs +++ b/src/queue/creator.rs @@ -190,7 +190,7 @@ mod tests { let template = "ID: {{ id }}\nContext: {{ context }}"; let mut values = HashMap::new(); values.insert("id".to_string(), "FIX-5678".to_string()); - values.insert("context".to_string(), "".to_string()); + values.insert("context".to_string(), String::new()); let result = render_template(template, &values).unwrap(); @@ -268,17 +268,17 @@ mod tests { #[test] fn test_step_omitted_when_empty() { // Step should be omitted from frontmatter when empty/falsey - let template = r#"--- + let template = r"--- id: {{ id }} {{#if step }}step: {{ step }} {{/if}}status: {{ status }} --- # Feature: {{ summary }} -"#; +"; let mut values = HashMap::new(); values.insert("id".to_string(), "FEAT-1234".to_string()); - values.insert("step".to_string(), "".to_string()); // Empty step + values.insert("step".to_string(), String::new()); // Empty step values.insert("status".to_string(), "queued".to_string()); values.insert("summary".to_string(), "Test feature".to_string()); @@ -296,14 +296,14 @@ id: {{ id }} #[test] fn test_step_included_when_present() { // Step should be included when it has a value - let template = r#"--- + let template = r"--- id: {{ id }} {{#if step }}step: {{ step }} {{/if}}status: {{ status }} --- # Feature: {{ summary }} -"#; +"; let mut values = HashMap::new(); values.insert("id".to_string(), "FEAT-1234".to_string()); values.insert("step".to_string(), "plan".to_string()); // Non-empty step @@ -327,17 +327,17 @@ id: {{ id }} // and empty step values should be omitted // Using the fixed feature.md template pattern - let template = r#"--- + let template = r"--- id: {{ id }} {{#if step }}step: {{ step }} {{/if}}status: {{ status }} --- # Feature: {{ summary }} -"#; +"; let mut values = HashMap::new(); values.insert("id".to_string(), "FEAT-1234".to_string()); - values.insert("step".to_string(), "".to_string()); // Empty step + values.insert("step".to_string(), String::new()); // Empty step values.insert("status".to_string(), "queued".to_string()); values.insert("summary".to_string(), "Test feature".to_string()); diff --git a/src/queue/mod.rs b/src/queue/mod.rs index 5e6a2e4..6333706 100644 --- a/src/queue/mod.rs +++ b/src/queue/mod.rs @@ -297,10 +297,9 @@ mod tests { ticket_type: &str, project: &str, ) { - let filename = format!("{}-{}-{}-summary.md", timestamp, ticket_type, project); + let filename = format!("{timestamp}-{ticket_type}-{project}-summary.md"); let content = format!( - "---\npriority: P2-medium\n---\n# {}: Test Summary\n\nDescription here.", - ticket_type + "---\npriority: P2-medium\n---\n# {ticket_type}: Test Summary\n\nDescription here." ); fs::write(dir.join(&filename), content).unwrap(); } diff --git a/src/queue/ticket.rs b/src/queue/ticket.rs index cbcecae..fe226c6 100644 --- a/src/queue/ticket.rs +++ b/src/queue/ticket.rs @@ -781,12 +781,12 @@ mod tests { #[test] fn test_extract_summary_from_feature_header() { // Summary should be extracted from "# Feature: X" format - let content = r#" + let content = r" # Feature: Add user authentication ## Context This is the context. -"#; +"; let summary = extract_summary(content); assert_eq!(summary, "Add user authentication"); } @@ -794,48 +794,48 @@ This is the context. #[test] fn test_extract_summary_from_fix_header() { // Summary should be extracted from "# Fix: X" format - let content = r#" + let content = r" # Fix: Resolve login timeout issue ## Context Users are experiencing timeouts. -"#; +"; let summary = extract_summary(content); assert_eq!(summary, "Resolve login timeout issue"); } #[test] fn test_extract_summary_from_spike_header() { - let content = r#" + let content = r" # Spike: Investigate caching strategies ## Context Need to explore caching options. -"#; +"; let summary = extract_summary(content); assert_eq!(summary, "Investigate caching strategies"); } #[test] fn test_extract_summary_from_investigation_header() { - let content = r#" + let content = r" # Investigation: Database connection failures ## Observed Behavior Connections are dropping. -"#; +"; let summary = extract_summary(content); assert_eq!(summary, "Database connection failures"); } #[test] fn test_extract_summary_from_task_header() { - let content = r#" + let content = r" # Task: Update dependencies ## Context Routine maintenance. -"#; +"; let summary = extract_summary(content); assert_eq!(summary, "Update dependencies"); } @@ -843,13 +843,13 @@ Routine maintenance. #[test] fn test_extract_summary_from_summary_section() { // Legacy format with ## Summary section should still work - let content = r#" + let content = r" ## Summary This is the summary text. ## Details More details here. -"#; +"; let summary = extract_summary(content); assert_eq!(summary, "This is the summary text."); } @@ -857,9 +857,9 @@ More details here. #[test] fn test_extract_summary_fallback_to_first_line() { // When no recognized format, should fall back to first non-header line - let content = r#" + let content = r" This is just some text without headers. -"#; +"; let summary = extract_summary(content); assert_eq!(summary, "This is just some text without headers."); } @@ -874,33 +874,32 @@ This is just some text without headers. #[test] fn test_extract_frontmatter_with_empty_step() { // Frontmatter with empty step should return empty string - let content = r#"--- + let content = r"--- id: FEAT-1234 step: status: queued --- # Feature: Test feature -"#; +"; let (frontmatter, _sessions, _llm_task, _body) = extract_frontmatter(content).unwrap(); let step = frontmatter.get("step").cloned().unwrap_or_default(); assert!( step.is_empty(), - "Empty step should be empty string, got: '{}'", - step + "Empty step should be empty string, got: '{step}'" ); } #[test] fn test_extract_frontmatter_without_step() { // Frontmatter without step field should be handled gracefully - let content = r#"--- + let content = r"--- id: FEAT-1234 status: queued --- # Feature: Test feature -"#; +"; let (frontmatter, _sessions, _llm_task, _body) = extract_frontmatter(content).unwrap(); let step = frontmatter.get("step").cloned().unwrap_or_default(); assert!( @@ -913,14 +912,14 @@ status: queued fn test_ticket_id_does_not_duplicate_type() { // The ticket.id field should be the full ID like "FEAT-1234" // and should NOT be duplicated when displayed - let content = r#"--- + let content = r"--- id: FEAT-7598 status: queued project: operator --- # Feature: Test summary -"#; +"; // Create temp file for testing let temp_dir = tempfile::tempdir().unwrap(); @@ -947,7 +946,7 @@ project: operator #[test] fn test_sessions_frontmatter_parsing() { // Frontmatter with sessions should parse correctly - let content = r#"--- + let content = r"--- id: FEAT-1234 status: running step: implement @@ -957,7 +956,7 @@ sessions: --- # Feature: Test feature -"#; +"; let (frontmatter, sessions, _llm_task, _body) = extract_frontmatter(content).unwrap(); assert_eq!(frontmatter.get("id").unwrap(), "FEAT-1234"); assert_eq!(sessions.len(), 2); @@ -973,20 +972,20 @@ sessions: #[test] fn test_sessions_empty_when_not_present() { - let content = r#"--- + let content = r"--- id: FEAT-1234 status: queued --- # Feature: Test feature -"#; +"; let (_frontmatter, sessions, _llm_task, _body) = extract_frontmatter(content).unwrap(); assert!(sessions.is_empty()); } #[test] fn test_ticket_from_file_with_sessions() { - let content = r#"--- + let content = r"--- id: FEAT-5678 status: running step: implement @@ -996,7 +995,7 @@ sessions: --- # Feature: Test with sessions -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir.path().join("20241221-1430-FEAT-operator-test.md"); std::fs::write(&ticket_path, content).unwrap(); @@ -1016,14 +1015,14 @@ sessions: #[test] fn test_set_session_id() { - let content = r#"--- + let content = r"--- id: FEAT-9999 status: queued step: plan --- # Feature: Test set session -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir.path().join("20241221-1430-FEAT-operator-test.md"); std::fs::write(&ticket_path, content).unwrap(); @@ -1044,14 +1043,14 @@ step: plan #[test] fn test_set_multiple_session_ids() { - let content = r#"--- + let content = r"--- id: FEAT-8888 status: queued step: plan --- # Feature: Test multiple sessions -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir.path().join("20241221-1430-FEAT-operator-test.md"); std::fs::write(&ticket_path, content).unwrap(); @@ -1083,7 +1082,7 @@ step: plan #[test] fn test_llm_task_frontmatter_parsing() { // Frontmatter with llm_task should parse correctly - let content = r#"--- + let content = r"--- id: FEAT-1234 status: running step: implement @@ -1096,7 +1095,7 @@ llm_task: --- # Feature: Test feature -"#; +"; let (_frontmatter, _sessions, llm_task, _body) = extract_frontmatter(content).unwrap(); assert_eq!( llm_task.id, @@ -1110,20 +1109,20 @@ llm_task: #[test] fn test_llm_task_empty_when_not_present() { - let content = r#"--- + let content = r"--- id: FEAT-1234 status: queued --- # Feature: Test feature -"#; +"; let (_frontmatter, _sessions, llm_task, _body) = extract_frontmatter(content).unwrap(); assert_eq!(llm_task, LlmTask::default()); } #[test] fn test_ticket_from_file_with_llm_task() { - let content = r#"--- + let content = r"--- id: FEAT-5678 status: running step: implement @@ -1133,7 +1132,7 @@ llm_task: --- # Feature: Test with LLM task -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir.path().join("20241221-1430-FEAT-operator-test.md"); std::fs::write(&ticket_path, content).unwrap(); @@ -1147,14 +1146,14 @@ llm_task: #[test] fn test_set_llm_task_id() { - let content = r#"--- + let content = r"--- id: FEAT-9999 status: queued step: plan --- # Feature: Test set LLM task -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir.path().join("20241221-1430-FEAT-operator-test.md"); std::fs::write(&ticket_path, content).unwrap(); @@ -1175,14 +1174,14 @@ step: plan #[test] fn test_set_llm_task_status() { - let content = r#"--- + let content = r"--- id: FEAT-7777 status: queued step: plan --- # Feature: Test set LLM task status -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir.path().join("20241221-1430-FEAT-operator-test.md"); std::fs::write(&ticket_path, content).unwrap(); @@ -1202,14 +1201,14 @@ step: plan #[test] fn test_set_llm_task_blocked_by() { - let content = r#"--- + let content = r"--- id: FEAT-6666 status: queued step: plan --- # Feature: Test set LLM task blocked_by -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir.path().join("20241221-1430-FEAT-operator-test.md"); std::fs::write(&ticket_path, content).unwrap(); @@ -1231,14 +1230,14 @@ step: plan #[test] fn test_advance_step_returns_switch_agent() { // FEAT "build" step has next_step "code", and "code" has agent: "claude-opus" - let content = r#"--- + let content = r"--- id: FEAT-2001 status: running step: build --- # Feature: Test advance with agent switch -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir .path() @@ -1261,14 +1260,14 @@ step: build #[test] fn test_advance_step_no_switch_agent() { // FEAT "plan" step has next_step "build", and "build" has no agent override - let content = r#"--- + let content = r"--- id: FEAT-2002 status: running step: plan --- # Feature: Test advance without agent switch -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir .path() @@ -1291,14 +1290,14 @@ step: plan #[test] fn test_advance_step_final() { // FEAT "deploy" is the last step (no next_step) - let content = r#"--- + let content = r"--- id: FEAT-2003 status: running step: deploy --- # Feature: Test advance from final step -"#; +"; let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir .path() diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 6bb3b1d..ba6b1e5 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -147,9 +147,8 @@ mod tests { let spec = ApiDoc::json().expect("Failed to generate OpenAPI spec"); let cargo_version = env!("CARGO_PKG_VERSION"); assert!( - spec.contains(&format!("\"version\": \"{}\"", cargo_version)), - "OpenAPI version should match Cargo.toml version ({}), but spec contains different version", - cargo_version + spec.contains(&format!("\"version\": \"{cargo_version}\"")), + "OpenAPI version should match Cargo.toml version ({cargo_version}), but spec contains different version" ); } } diff --git a/src/rest/routes/collections.rs b/src/rest/routes/collections.rs index c53218b..348e36c 100644 --- a/src/rest/routes/collections.rs +++ b/src/rest/routes/collections.rs @@ -164,8 +164,7 @@ mod tests { let collections: Vec<_> = registry.all_collections().map(|c| c.name.clone()).collect(); assert!( collections.contains(&"simple".to_string()), - "Expected 'simple' collection, found: {:?}", - collections + "Expected 'simple' collection, found: {collections:?}" ); } diff --git a/src/rest/routes/queue.rs b/src/rest/routes/queue.rs index b7e10ae..915cca2 100644 --- a/src/rest/routes/queue.rs +++ b/src/rest/routes/queue.rs @@ -379,7 +379,7 @@ mod tests { let ticket_path = temp_dir.path().join("20241229-1430-FEAT-operator-test.md"); std::fs::write( &ticket_path, - r#"--- + r"--- id: FEAT-1234 status: queued priority: P2-medium @@ -387,7 +387,7 @@ step: plan --- # Feature: Test ticket for kanban -"#, +", ) .unwrap(); diff --git a/src/services/kanban_sync.rs b/src/services/kanban_sync.rs index 03dec0e..96eeb11 100644 --- a/src/services/kanban_sync.rs +++ b/src/services/kanban_sync.rs @@ -455,7 +455,7 @@ mod tests { #[test] fn test_extract_external_id() { - let content = r#"--- + let content = r"--- id: FEAT-123 status: queued external_id: PROJ-456 @@ -463,19 +463,19 @@ external_url: https://example.com --- # Content here -"#; +"; assert_eq!(extract_external_id(content), Some("PROJ-456".to_string())); } #[test] fn test_extract_external_id_missing() { - let content = r#"--- + let content = r"--- id: FEAT-123 status: queued --- # No external_id -"#; +"; assert_eq!(extract_external_id(content), None); } diff --git a/src/startup/mod.rs b/src/startup/mod.rs index 9f4c4bb..9cd7a51 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -160,7 +160,7 @@ mod tests { fn test_step_names_are_unique() { let names: Vec<&str> = SETUP_STEPS.iter().map(|s| s.name).collect(); let mut unique_names = names.clone(); - unique_names.sort(); + unique_names.sort_unstable(); unique_names.dedup(); assert_eq!( names.len(), diff --git a/src/state.rs b/src/state.rs index c0b566e..66a9f07 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1152,7 +1152,7 @@ mod tests { for i in 0..101 { let id = state .add_agent( - format!("FEAT-{:03}", i), + format!("FEAT-{i:03}"), "FEAT".to_string(), "test".to_string(), false, @@ -1160,7 +1160,7 @@ mod tests { .unwrap(); state - .complete_agent(&id, format!("Summary {}", i), None, vec![]) + .complete_agent(&id, format!("Summary {i}"), None, vec![]) .unwrap(); } diff --git a/src/steps/session.rs b/src/steps/session.rs index fcd4e65..87d77b6 100644 --- a/src/steps/session.rs +++ b/src/steps/session.rs @@ -304,14 +304,14 @@ mod tests { // Create a temp file for the ticket let temp_dir = tempfile::tempdir().unwrap(); let ticket_path = temp_dir.path().join("20241221-1430-FEAT-gamesvc-test.md"); - let content = r#"--- + let content = r"--- id: FEAT-1234 status: queued step: plan --- # Feature: Test feature -"#; +"; std::fs::write(&ticket_path, content).unwrap(); let mut ticket = crate::queue::Ticket::from_file(&ticket_path).unwrap(); diff --git a/src/ui/collection_dialog.rs b/src/ui/collection_dialog.rs index b6f28f7..00cf6b4 100644 --- a/src/ui/collection_dialog.rs +++ b/src/ui/collection_dialog.rs @@ -356,14 +356,14 @@ mod tests { dialog.collections = vec![ CollectionInfo { name: "a".to_string(), - description: "".to_string(), + description: String::new(), type_count: 1, is_builtin: true, sync_source: None, }, CollectionInfo { name: "b".to_string(), - description: "".to_string(), + description: String::new(), type_count: 2, is_builtin: false, sync_source: None, @@ -390,7 +390,7 @@ mod tests { let mut dialog = CollectionSwitchDialog::new(); dialog.collections = vec![CollectionInfo { name: "test".to_string(), - description: "".to_string(), + description: String::new(), type_count: 1, is_builtin: false, sync_source: None, @@ -412,7 +412,7 @@ mod tests { let mut dialog = CollectionSwitchDialog::new(); dialog.collections = vec![CollectionInfo { name: "test".to_string(), - description: "".to_string(), + description: String::new(), type_count: 1, is_builtin: false, sync_source: None, diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index f912153..d5f1ccc 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -335,8 +335,7 @@ mod tests { let numeric_part: &str = part.split('-').next().unwrap_or(part); assert!( numeric_part.parse::().is_ok(), - "Version component '{}' should be numeric", - part + "Version component '{part}' should be numeric" ); } } diff --git a/src/ui/dialogs/confirm.rs b/src/ui/dialogs/confirm.rs index 9510367..f3b5c65 100644 --- a/src/ui/dialogs/confirm.rs +++ b/src/ui/dialogs/confirm.rs @@ -598,8 +598,8 @@ mod tests { fn make_test_ticket(project: &str) -> Ticket { Ticket { - filename: format!("20241225-1200-TASK-{}-test.md", project), - filepath: format!("/tmp/tickets/queue/20241225-1200-TASK-{}-test.md", project), + filename: format!("20241225-1200-TASK-{project}-test.md"), + filepath: format!("/tmp/tickets/queue/20241225-1200-TASK-{project}-test.md"), timestamp: "20241225-1200".to_string(), ticket_type: "TASK".to_string(), project: project.to_string(), @@ -679,7 +679,7 @@ mod tests { ]; let projects = vec!["project-a".to_string(), "project-b".to_string()]; - dialog.configure(providers.clone(), projects.clone(), true, false); + dialog.configure(providers, projects, true, false); assert_eq!(dialog.provider_options.len(), 2); assert_eq!(dialog.project_options.len(), 2); diff --git a/src/ui/dialogs/session_recovery.rs b/src/ui/dialogs/session_recovery.rs index 67bad33..e1050ee 100644 --- a/src/ui/dialogs/session_recovery.rs +++ b/src/ui/dialogs/session_recovery.rs @@ -150,7 +150,7 @@ impl SessionRecoveryDialog { } } - /// Make the available_options method accessible for testing + /// Make the `available_options` method accessible for testing #[cfg(test)] pub fn available_options_for_test(&self) -> Vec { self.available_options() diff --git a/src/ui/panels.rs b/src/ui/panels.rs index 48682de..4210c8e 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -654,8 +654,7 @@ mod tests { // Should NOT have the duplicated type prefix assert!( !display_id.starts_with("FEAT-FEAT"), - "Display ID should not have duplicated prefix, got: {}", - display_id + "Display ID should not have duplicated prefix, got: {display_id}" ); assert_eq!(display_id, "FEAT-7598"); } @@ -675,8 +674,7 @@ mod tests { let result = format_display_id(input); assert_eq!( result, expected, - "format_display_id({}) should return {}, got {}", - input, expected, result + "format_display_id({input}) should return {expected}, got {result}" ); } } diff --git a/tests/feature_parity_test.rs b/tests/feature_parity_test.rs index 8348bb9..f09efc0 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -2,7 +2,7 @@ //! //! Ensures that Core Operations are available across all session management tools: //! - TUI (keybindings) -//! - VSCode Extension (commands in package.json) +//! - `VSCode` Extension (commands in package.json) //! - REST API (endpoints) //! //! Core Operations: @@ -36,7 +36,7 @@ fn get_keybinding_descriptions() -> Vec { } /// Core Operations that must be supported by all session management tools. -/// Each tuple: (TUI description pattern, VSCode command, API endpoint) +/// Each tuple: (TUI description pattern, `VSCode` command, API endpoint) const CORE_OPERATIONS: &[(&str, &str, &str)] = &[ ( "Sync kanban", @@ -77,13 +77,12 @@ fn test_tui_has_all_core_operations() { assert!( found, - "TUI should have keybinding containing '{}'\nAvailable keybindings: {:?}", - tui_pattern, descriptions + "TUI should have keybinding containing '{tui_pattern}'\nAvailable keybindings: {descriptions:?}" ); } } -/// Test that VSCode extension has commands for all Core Operations +/// Test that `VSCode` extension has commands for all Core Operations #[test] fn test_vscode_extension_has_all_core_operations() { // Read package.json from vscode-extension @@ -92,8 +91,7 @@ fn test_vscode_extension_has_all_core_operations() { for (_, vscode_cmd, _) in CORE_OPERATIONS { assert!( package_json.contains(vscode_cmd), - "VSCode extension should have command '{}' in package.json", - vscode_cmd + "VSCode extension should have command '{vscode_cmd}' in package.json" ); } } @@ -218,7 +216,7 @@ mod detailed_tests { assert!(reject_exists, "Reject review shortcut should exist"); } - /// Verify VSCode commands have proper titles + /// Verify `VSCode` commands have proper titles #[test] fn test_vscode_command_titles() { let package_json = include_str!("../vscode-extension/package.json"); diff --git a/tests/git_integration.rs b/tests/git_integration.rs index 1da5b2d..866aa22 100644 --- a/tests/git_integration.rs +++ b/tests/git_integration.rs @@ -1,4 +1,4 @@ -//! Integration tests for Git CLI and WorktreeManager +//! Integration tests for Git CLI and `WorktreeManager` //! //! These tests use the actual operator repository for realistic testing. //! All test artifacts use the `optest/` branch prefix for identification and cleanup. @@ -81,7 +81,7 @@ fn test_branch_name(suffix: &str) -> String { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); - format!("{}{}-{}", TEST_BRANCH_PREFIX, suffix, timestamp) + format!("{TEST_BRANCH_PREFIX}{suffix}-{timestamp}") } /// Generate a unique test ticket ID @@ -104,7 +104,7 @@ async fn get_default_branch(repo_path: &Path) -> String { if let Ok(ref_str) = GitCli::symbolic_ref(repo_path, "refs/remotes/origin/HEAD").await { // refs/remotes/origin/main -> origin/main if let Some(branch) = ref_str.strip_prefix("refs/remotes/origin/") { - return format!("origin/{}", branch); + return format!("origin/{branch}"); } } // Fallback to origin/main (works in CI where local main doesn't exist) @@ -116,7 +116,7 @@ async fn get_default_branch(repo_path: &Path) -> String { mod git_cli_readonly_tests { use super::*; - /// Test: Verify current_branch returns a valid branch name + /// Test: Verify `current_branch` returns a valid branch name #[tokio::test] async fn test_current_branch_returns_valid_name() { skip_if_not_configured!(); @@ -131,10 +131,10 @@ mod git_cli_readonly_tests { !branch.contains('\n'), "Branch name should not contain newlines" ); - eprintln!("Current branch: {}", branch); + eprintln!("Current branch: {branch}"); } - /// Test: Verify repo_root returns a path containing 'operator' + /// Test: Verify `repo_root` returns a path containing 'operator' #[tokio::test] async fn test_repo_root_detection() { skip_if_not_configured!(); @@ -146,14 +146,13 @@ mod git_cli_readonly_tests { assert!( root.contains("operator"), - "Root should contain 'operator': {}", - root + "Root should contain 'operator': {root}" ); assert!(Path::new(&root).exists(), "Root path should exist"); - eprintln!("Repo root: {}", root); + eprintln!("Repo root: {root}"); } - /// Test: Verify is_dirty returns a boolean without error + /// Test: Verify `is_dirty` returns a boolean without error #[tokio::test] async fn test_is_dirty_returns_bool() { skip_if_not_configured!(); @@ -163,11 +162,11 @@ mod git_cli_readonly_tests { .await .expect("Should check dirty status"); - eprintln!("Repository dirty: {}", is_dirty); + eprintln!("Repository dirty: {is_dirty}"); // We can't assert the value, but we verify it doesn't error } - /// Test: Verify is_worktree correctly identifies a git worktree + /// Test: Verify `is_worktree` correctly identifies a git worktree #[tokio::test] async fn test_is_worktree_in_repo() { skip_if_not_configured!(); @@ -180,7 +179,7 @@ mod git_cli_readonly_tests { assert!(is_wt, "Operator repo should be inside a work tree"); } - /// Test: Verify is_worktree returns false for non-repo paths + /// Test: Verify `is_worktree` returns false for non-repo paths #[tokio::test] async fn test_is_worktree_outside_repo() { skip_if_not_configured!(); @@ -193,7 +192,7 @@ mod git_cli_readonly_tests { assert!(!is_wt, "Temp directory should not be a worktree"); } - /// Test: Verify list_worktrees returns at least the main worktree + /// Test: Verify `list_worktrees` returns at least the main worktree #[tokio::test] async fn test_list_worktrees_has_main() { skip_if_not_configured!(); @@ -216,7 +215,7 @@ mod git_cli_readonly_tests { } } - /// Test: Verify head_commit returns a valid SHA + /// Test: Verify `head_commit` returns a valid SHA #[tokio::test] async fn test_head_commit_returns_sha() { skip_if_not_configured!(); @@ -229,14 +228,13 @@ mod git_cli_readonly_tests { assert_eq!(sha.len(), 40, "SHA should be 40 characters"); assert!( sha.chars().all(|c| c.is_ascii_hexdigit()), - "SHA should be hex: {}", - sha + "SHA should be hex: {sha}" ); - eprintln!("HEAD commit: {}", sha); + eprintln!("HEAD commit: {sha}"); } - /// Test: Verify get_remote_url returns origin URL + /// Test: Verify `get_remote_url` returns origin URL #[tokio::test] async fn test_get_remote_url() { skip_if_not_configured!(); @@ -248,14 +246,13 @@ mod git_cli_readonly_tests { assert!( url.contains("operator") || url.contains("gbqr"), - "Remote URL should reference operator: {}", - url + "Remote URL should reference operator: {url}" ); - eprintln!("Remote URL: {}", url); + eprintln!("Remote URL: {url}"); } - /// Test: Verify symbolic_ref can read origin/HEAD + /// Test: Verify `symbolic_ref` can read origin/HEAD #[tokio::test] async fn test_symbolic_ref_origin_head() { skip_if_not_configured!(); @@ -264,11 +261,11 @@ mod git_cli_readonly_tests { // This may fail if origin/HEAD isn't set, which is OK match GitCli::symbolic_ref(&repo_path, "refs/remotes/origin/HEAD").await { Ok(ref_str) => { - assert!(ref_str.starts_with("refs/"), "Should be a ref: {}", ref_str); - eprintln!("origin/HEAD points to: {}", ref_str); + assert!(ref_str.starts_with("refs/"), "Should be a ref: {ref_str}"); + eprintln!("origin/HEAD points to: {ref_str}"); } Err(e) => { - eprintln!("origin/HEAD not set (expected in some setups): {}", e); + eprintln!("origin/HEAD not set (expected in some setups): {e}"); } } } @@ -286,12 +283,12 @@ mod git_cli_remote_tests { let repo_path = get_repo_path(); let result = GitCli::fetch(&repo_path, "origin").await; - assert!(result.is_ok(), "Fetch should succeed: {:?}", result); + assert!(result.is_ok(), "Fetch should succeed: {result:?}"); eprintln!("Fetch from origin completed"); } - /// Test: Verify remote_branch_exists detects main/master + /// Test: Verify `remote_branch_exists` detects main/master #[tokio::test] async fn test_remote_branch_exists_main() { skip_if_not_configured!(); @@ -311,13 +308,10 @@ mod git_cli_remote_tests { "Either main or master should exist on remote" ); - eprintln!( - "main exists: {}, master exists: {}", - main_exists, master_exists - ); + eprintln!("main exists: {main_exists}, master exists: {master_exists}"); } - /// Test: Verify remote_branch_exists returns false for non-existent branch + /// Test: Verify `remote_branch_exists` returns false for non-existent branch #[tokio::test] async fn test_remote_branch_exists_nonexistent() { skip_if_not_configured!(); @@ -346,24 +340,16 @@ mod git_cli_branch_lifecycle_tests { let branch_name = test_branch_name("lifecycle"); let default_branch = get_default_branch(&repo_path).await; - eprintln!("Testing branch lifecycle: {}", branch_name); - eprintln!("Base branch: {}", default_branch); + eprintln!("Testing branch lifecycle: {branch_name}"); + eprintln!("Base branch: {default_branch}"); // Create branch from default branch let result = GitCli::create_branch(&repo_path, &branch_name, &default_branch).await; - assert!( - result.is_ok(), - "Branch creation should succeed: {:?}", - result - ); + assert!(result.is_ok(), "Branch creation should succeed: {result:?}"); // Push to remote with upstream let push_result = GitCli::push(&repo_path, "origin", &branch_name, true).await; - assert!( - push_result.is_ok(), - "Push should succeed: {:?}", - push_result - ); + assert!(push_result.is_ok(), "Push should succeed: {push_result:?}"); // Verify branch exists on remote let exists = GitCli::remote_branch_exists(&repo_path, "origin", &branch_name) @@ -375,8 +361,7 @@ mod git_cli_branch_lifecycle_tests { let delete_remote = GitCli::delete_remote_branch(&repo_path, "origin", &branch_name).await; assert!( delete_remote.is_ok(), - "Remote delete should succeed: {:?}", - delete_remote + "Remote delete should succeed: {delete_remote:?}" ); // Verify branch no longer exists on remote @@ -392,8 +377,7 @@ mod git_cli_branch_lifecycle_tests { let delete_local = GitCli::delete_branch(&repo_path, &branch_name, true).await; assert!( delete_local.is_ok(), - "Local delete should succeed: {:?}", - delete_local + "Local delete should succeed: {delete_local:?}" ); eprintln!("Branch lifecycle test completed successfully"); @@ -410,15 +394,11 @@ mod git_cli_branch_lifecycle_tests { // Create branch let result = GitCli::create_branch(&repo_path, &branch_name, &default_branch).await; - assert!( - result.is_ok(), - "Branch creation should succeed: {:?}", - result - ); + assert!(result.is_ok(), "Branch creation should succeed: {result:?}"); // Cleanup: delete local branch let delete = GitCli::delete_branch(&repo_path, &branch_name, true).await; - assert!(delete.is_ok(), "Cleanup should succeed: {:?}", delete); + assert!(delete.is_ok(), "Cleanup should succeed: {delete:?}"); eprintln!("Local branch test completed"); } @@ -441,7 +421,7 @@ mod git_cli_worktree_tests { let default_branch = get_default_branch(&repo_path).await; eprintln!("Creating worktree at: {}", worktree_path.display()); - eprintln!("Branch: {}, Base: {}", branch_name, default_branch); + eprintln!("Branch: {branch_name}, Base: {default_branch}"); // Add worktree with new branch let add_result = GitCli::add_worktree( @@ -454,8 +434,7 @@ mod git_cli_worktree_tests { .await; assert!( add_result.is_ok(), - "Worktree add should succeed: {:?}", - add_result + "Worktree add should succeed: {add_result:?}" ); // Verify worktree exists @@ -497,16 +476,14 @@ mod git_cli_worktree_tests { let remove_result = GitCli::remove_worktree(&repo_path, &worktree_path, false).await; assert!( remove_result.is_ok(), - "Worktree remove should succeed: {:?}", - remove_result + "Worktree remove should succeed: {remove_result:?}" ); // Prune worktree metadata let prune_result = GitCli::prune_worktrees(&repo_path).await; assert!( prune_result.is_ok(), - "Prune should succeed: {:?}", - prune_result + "Prune should succeed: {prune_result:?}" ); // Cleanup: delete the branch we created @@ -538,7 +515,7 @@ mod git_cli_worktree_tests { // This might fail because default branch is already checked out // That's expected behavior - eprintln!("Add existing branch result: {:?}", result); + eprintln!("Add existing branch result: {result:?}"); // Cleanup if it succeeded if result.is_ok() { @@ -553,7 +530,7 @@ mod git_cli_worktree_tests { let repo_path = get_repo_path(); let result = GitCli::prune_worktrees(&repo_path).await; - assert!(result.is_ok(), "Prune should succeed: {:?}", result); + assert!(result.is_ok(), "Prune should succeed: {result:?}"); eprintln!("Prune worktrees completed"); } @@ -565,7 +542,7 @@ mod git_cli_worktree_tests { let repo_path = get_repo_path(); let result = GitCli::repair_worktrees(&repo_path).await; - assert!(result.is_ok(), "Repair should succeed: {:?}", result); + assert!(result.is_ok(), "Repair should succeed: {result:?}"); eprintln!("Repair worktrees completed"); } @@ -576,7 +553,7 @@ mod git_cli_worktree_tests { mod worktree_manager_tests { use super::*; - /// Test: WorktreeManager path generation + /// Test: `WorktreeManager` path generation #[tokio::test] async fn test_worktree_path_generation() { let temp = TempDir::new().expect("Failed to create temp dir"); @@ -596,7 +573,7 @@ mod worktree_manager_tests { eprintln!("Generated path: {}", path.display()); } - /// Test: WorktreeManager path generation with various ticket IDs + /// Test: `WorktreeManager` path generation with various ticket IDs #[tokio::test] async fn test_worktree_path_normalization() { let temp = TempDir::new().expect("Failed to create temp dir"); @@ -614,7 +591,7 @@ mod worktree_manager_tests { eprintln!("Path 2: {}", path2.display()); } - /// Test: create_for_ticket full lifecycle + /// Test: `create_for_ticket` full lifecycle #[tokio::test] async fn test_create_for_ticket_lifecycle() { skip_if_not_configured!(); @@ -627,8 +604,8 @@ mod worktree_manager_tests { let ticket_id = test_ticket_id(); let branch_name = test_branch_name("manager-test"); - eprintln!("Creating worktree for ticket: {}", ticket_id); - eprintln!("Branch: {}", branch_name); + eprintln!("Creating worktree for ticket: {ticket_id}"); + eprintln!("Branch: {branch_name}"); // Create worktree let info = manager @@ -675,7 +652,7 @@ mod worktree_manager_tests { eprintln!("Worktree lifecycle completed successfully"); } - /// Test: ensure_worktree_exists idempotency + /// Test: `ensure_worktree_exists` idempotency #[tokio::test] async fn test_ensure_worktree_exists_idempotent() { skip_if_not_configured!(); @@ -725,7 +702,7 @@ mod worktree_manager_tests { .expect("Cleanup should succeed"); } - /// Test: list_project_worktrees empty project + /// Test: `list_project_worktrees` empty project #[tokio::test] async fn test_list_project_worktrees_empty() { let temp = TempDir::new().expect("Failed to create temp dir"); @@ -742,7 +719,7 @@ mod worktree_manager_tests { ); } - /// Test: list_project_worktrees with worktrees + /// Test: `list_project_worktrees` with worktrees #[tokio::test] async fn test_list_project_worktrees_with_entries() { skip_if_not_configured!(); @@ -798,7 +775,7 @@ mod worktree_manager_tests { .expect("Cleanup 2"); } - /// Test: cleanup_project_worktrees removes all + /// Test: `cleanup_project_worktrees` removes all #[tokio::test] async fn test_cleanup_project_worktrees() { skip_if_not_configured!(); @@ -844,7 +821,7 @@ mod worktree_manager_tests { mod git_cli_empty_repo_tests { use super::*; - /// Test: has_commits returns false for empty repo + /// Test: `has_commits` returns false for empty repo #[tokio::test] async fn test_has_commits_empty_repo() { skip_if_not_configured!(); @@ -864,7 +841,7 @@ mod git_cli_empty_repo_tests { assert!(!has, "Empty repo should have no commits"); } - /// Test: has_commits returns true for repo with commits + /// Test: `has_commits` returns true for repo with commits #[tokio::test] async fn test_has_commits_with_commit() { skip_if_not_configured!(); @@ -877,7 +854,7 @@ mod git_cli_empty_repo_tests { assert!(has, "Operator repo should have commits"); } - /// Test: create_for_ticket fails gracefully on empty repo + /// Test: `create_for_ticket` fails gracefully on empty repo #[tokio::test] async fn test_create_worktree_empty_repo_fails() { skip_if_not_configured!(); @@ -907,8 +884,7 @@ mod git_cli_empty_repo_tests { let err = result.unwrap_err().to_string(); assert!( err.contains("no commits"), - "Error should mention 'no commits': {}", - err + "Error should mention 'no commits': {err}" ); } } @@ -916,7 +892,7 @@ mod git_cli_empty_repo_tests { mod worktree_manager_error_tests { use super::*; - /// Test: create_for_ticket with invalid repo path + /// Test: `create_for_ticket` with invalid repo path #[tokio::test] async fn test_create_invalid_repo_path() { let temp = TempDir::new().expect("Failed to create temp dir"); @@ -958,7 +934,7 @@ mod worktree_manager_error_tests { .await; // May succeed if it can recover, or fail with appropriate error - eprintln!("Result when path exists but isn't worktree: {:?}", result); + eprintln!("Result when path exists but isn't worktree: {result:?}"); // Cleanup let _ = std::fs::remove_dir_all(&expected_path); diff --git a/tests/kanban_integration.rs b/tests/kanban_integration.rs index d56a8d0..a718b31 100644 --- a/tests/kanban_integration.rs +++ b/tests/kanban_integration.rs @@ -82,7 +82,7 @@ fn test_issue_title(suffix: &str) -> String { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis(); - format!("[OPTEST] {} - {}", suffix, uuid) + format!("[OPTEST] {suffix} - {uuid}") } /// Find a terminal status (Done, Complete, Completed, Closed, Resolved) from available statuses. @@ -117,12 +117,12 @@ async fn jira_credentials_valid() -> bool { valid } Err(e) => { - eprintln!("Jira credentials validation failed: {}", e); + eprintln!("Jira credentials validation failed: {e}"); false } }, Err(e) => { - eprintln!("Jira provider initialization failed: {}", e); + eprintln!("Jira provider initialization failed: {e}"); false } } @@ -150,12 +150,12 @@ async fn linear_credentials_valid() -> bool { valid } Err(e) => { - eprintln!("Linear credentials validation failed: {}", e); + eprintln!("Linear credentials validation failed: {e}"); false } }, Err(e) => { - eprintln!("Linear provider initialization failed: {}", e); + eprintln!("Linear provider initialization failed: {e}"); false } } @@ -195,7 +195,7 @@ mod jira_tests { let provider = get_provider(); let result = provider.test_connection().await; - assert!(result.is_ok(), "Connection test failed: {:?}", result); + assert!(result.is_ok(), "Connection test failed: {result:?}"); assert!(result.unwrap(), "Connection should be valid"); } @@ -251,7 +251,7 @@ mod jira_tests { .expect("Should list statuses"); assert!(!statuses.is_empty(), "Should have workflow statuses"); - eprintln!("Available Jira statuses: {:?}", statuses); + eprintln!("Available Jira statuses: {statuses:?}"); } #[tokio::test] @@ -375,7 +375,7 @@ mod jira_tests { .cloned(); if let Some(target) = target_status { - eprintln!("Transitioning to: {}", target); + eprintln!("Transitioning to: {target}"); let update_request = UpdateStatusRequest { status: target.clone(), @@ -391,7 +391,7 @@ mod jira_tests { // Status may not match exactly due to workflow rules } Err(e) => { - eprintln!("Transition failed (may be expected): {}", e); + eprintln!("Transition failed (may be expected): {e}"); } } } else { @@ -415,7 +415,7 @@ mod linear_tests { let provider = get_provider(); let result = provider.test_connection().await; - assert!(result.is_ok(), "Connection test failed: {:?}", result); + assert!(result.is_ok(), "Connection test failed: {result:?}"); assert!(result.unwrap(), "Connection should be valid"); } @@ -469,7 +469,7 @@ mod linear_tests { .expect("Should list statuses"); assert!(!statuses.is_empty(), "Should have workflow states"); - eprintln!("Available Linear statuses: {:?}", statuses); + eprintln!("Available Linear statuses: {statuses:?}"); } #[tokio::test] @@ -614,7 +614,7 @@ mod linear_tests { let mut current_status = created.issue.status.clone(); if let Some(target) = target_status { - eprintln!("Transitioning to: {}", target); + eprintln!("Transitioning to: {target}"); let update_request = UpdateStatusRequest { status: target.clone(), @@ -639,11 +639,10 @@ mod linear_tests { // ─── Cleanup: Move issue to terminal status (Done) ───────────────────────── if let Some(done_status) = terminal_status { // Only transition if not already in terminal status - if !current_status.eq_ignore_ascii_case(&done_status) { - eprintln!( - "Cleanup: Transitioning issue to terminal status: {}", - done_status - ); + if current_status.eq_ignore_ascii_case(&done_status) { + eprintln!("Issue already in terminal status: {current_status}"); + } else { + eprintln!("Cleanup: Transitioning issue to terminal status: {done_status}"); let done_request = UpdateStatusRequest { status: done_status.clone(), @@ -666,20 +665,14 @@ mod linear_tests { } Err(e) => { eprintln!( - "Warning: Could not move issue to terminal status '{}': {}", - done_status, e + "Warning: Could not move issue to terminal status '{done_status}': {e}" ); // Don't fail the test - cleanup is best-effort } } - } else { - eprintln!("Issue already in terminal status: {}", current_status); } } else { - eprintln!( - "Warning: No terminal status found in available statuses: {:?}", - statuses - ); + eprintln!("Warning: No terminal status found in available statuses: {statuses:?}"); } } } diff --git a/tests/launch_common/mod.rs b/tests/launch_common/mod.rs index 98133da..4194642 100644 --- a/tests/launch_common/mod.rs +++ b/tests/launch_common/mod.rs @@ -124,15 +124,14 @@ impl LaunchTestContext { // Generate wrapper-specific sessions config let sessions_config = match mode { WrapperTestMode::Tmux => String::new(), // default is tmux, no [sessions] needed - WrapperTestMode::Zellij => format!( - r#" + WrapperTestMode::Zellij => r#" [sessions] wrapper = "zellij" [sessions.zellij] require_in_zellij = false "# - ), + .to_string(), WrapperTestMode::Cmux => format!( r#" [sessions] @@ -316,16 +315,13 @@ config_generated = false let timestamp = chrono::Local::now().format("%Y%m%d-%H%M").to_string(); // Use "testproject" (no hyphen) to match the project directory let description = ticket_id.to_lowercase().replace('-', "_"); - let filename = format!( - "{}-{}-testproject-{}.md", - timestamp, ticket_type, description - ); + let filename = format!("{timestamp}-{ticket_type}-testproject-{description}.md"); let path = self.tickets_path.join("queue").join(&filename); fs::write(&path, content).unwrap(); path } - /// Create a DEFINITION_OF_DONE.md template file + /// Create a `DEFINITION_OF_DONE.md` template file pub fn create_definition_of_done(&self, content: &str) { let path = self .tickets_path @@ -333,7 +329,7 @@ config_generated = false fs::write(path, content).unwrap(); } - /// Create ACCEPTANCE_CRITERIA.md template file + /// Create `ACCEPTANCE_CRITERIA.md` template file pub fn create_acceptance_criteria(&self, content: &str) { let path = self .tickets_path diff --git a/tests/launch_integration.rs b/tests/launch_integration.rs index d9e0c06..d335473 100644 --- a/tests/launch_integration.rs +++ b/tests/launch_integration.rs @@ -61,7 +61,7 @@ macro_rules! skip_if_not_configured { }; } -/// Tmux test wrapper providing session management on top of LaunchTestContext +/// Tmux test wrapper providing session management on top of `LaunchTestContext` struct TmuxTestContext { ctx: LaunchTestContext, } @@ -92,7 +92,7 @@ impl TmuxTestContext { Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout) .lines() .filter(|s| s.starts_with("op-")) - .map(|s| s.to_string()) + .map(std::string::ToString::to_string) .collect(), _ => Vec::new(), } @@ -141,7 +141,7 @@ fn test_launch_creates_tmux_session() { let tctx = TmuxTestContext::new("creates_session"); // Create a TASK ticket (uses simple fallback prompt) - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-001 priority: P2-medium status: queued @@ -151,7 +151,7 @@ status: queued ## Context This is a test task to verify tmux session creation. -"#; +"; tctx.ctx.create_ticket("TASK", "TASK-001", ticket_content); // Run launch @@ -160,8 +160,8 @@ This is a test task to verify tmux session creation. // Check output let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("stdout: {}", stdout); - eprintln!("stderr: {}", stderr); + eprintln!("stdout: {stdout}"); + eprintln!("stderr: {stderr}"); // Wait for session to be created std::thread::sleep(Duration::from_secs(2)); @@ -170,8 +170,7 @@ This is a test task to verify tmux session creation. let sessions = tctx.list_test_sessions(); assert!( sessions.iter().any(|s| s.contains("TASK-001")), - "Tmux session should be created. Sessions: {:?}", - sessions + "Tmux session should be created. Sessions: {sessions:?}" ); // Verify ticket was moved to in-progress @@ -188,7 +187,7 @@ fn test_prompt_file_contains_ticket_content() { let tctx = TmuxTestContext::new("prompt_content"); // Create a TASK ticket - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-002 priority: P2-medium status: queued @@ -198,7 +197,7 @@ status: queued ## Context This content should appear in the prompt file: UNIQUE_MARKER_12345 -"#; +"; tctx.ctx.create_ticket("TASK", "TASK-002", ticket_content); // Run launch @@ -231,14 +230,14 @@ fn test_llm_command_has_session_id_and_model() { let tctx = TmuxTestContext::new("command_structure"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-003 priority: P2-medium status: queued --- # Task: Test command structure -"#; +"; tctx.ctx.create_ticket("TASK", "TASK-003", ticket_content); tctx.ctx.run_launch(&[]); @@ -285,7 +284,7 @@ fn test_prompt_file_is_written_to_disk() { let tctx = TmuxTestContext::new("prompt_file"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-004 priority: P2-medium status: queued @@ -295,7 +294,7 @@ status: queued ## Context PROMPT_FILE_MARKER_67890 -"#; +"; tctx.ctx.create_ticket("TASK", "TASK-004", ticket_content); tctx.ctx.run_launch(&[]); @@ -312,8 +311,7 @@ PROMPT_FILE_MARKER_67890 .any(|p| p.contains("TASK-004") || p.contains("task_004")); assert!( has_reference, - "Prompt file should reference ticket. Prompts: {:?}", - prompts + "Prompt file should reference ticket. Prompts: {prompts:?}" ); } @@ -335,14 +333,14 @@ fn test_session_already_exists_error() { "Pre-created session should exist" ); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-005 priority: P2-medium status: queued --- # Task: Test session conflict -"#; +"; tctx.ctx.create_ticket("TASK", "TASK-005", ticket_content); let output = tctx.ctx.run_launch(&[]); @@ -350,7 +348,7 @@ status: queued let stdout = String::from_utf8_lossy(&output.stdout); // Should either fail or output an error about existing session - let combined = format!("{}{}", stdout, stderr); + let combined = format!("{stdout}{stderr}"); assert!( !output.status.success() || combined.contains("already exists"), "Should error when session already exists. Status: {}, Output: {}", @@ -369,7 +367,7 @@ fn test_ticket_with_empty_step_uses_default() { let tctx = TmuxTestContext::new("empty_step"); // Ticket without explicit step - let ticket_content = r#"--- + let ticket_content = r"--- id: FEAT-001 priority: P2-medium status: queued @@ -379,14 +377,14 @@ status: queued ## Context When step is not specified, should use first step from template. -"#; +"; tctx.ctx.create_ticket("FEAT", "FEAT-001", ticket_content); let output = tctx.ctx.run_launch(&[]); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); - eprintln!("stdout: {}", stdout); - eprintln!("stderr: {}", stderr); + eprintln!("stdout: {stdout}"); + eprintln!("stderr: {stderr}"); std::thread::sleep(Duration::from_secs(2)); @@ -394,8 +392,7 @@ When step is not specified, should use first step from template. let sessions = tctx.list_test_sessions(); assert!( sessions.iter().any(|s| s.contains("FEAT-001")), - "Should create session for FEAT ticket. Sessions: {:?}", - sessions + "Should create session for FEAT ticket. Sessions: {sessions:?}" ); } @@ -407,14 +404,14 @@ fn test_definition_of_done_included_in_prompt() { // Create definition of done tctx.ctx.create_definition_of_done( - r#"- All tests pass + r"- All tests pass - Code reviewed and approved - Documentation updated -- DOD_MARKER_UNIQUE_11111"#, +- DOD_MARKER_UNIQUE_11111", ); // Create FEAT ticket (which should use template.prompt with {{ definition_of_done }}) - let ticket_content = r#"--- + let ticket_content = r"--- id: FEAT-002 priority: P2-medium status: queued @@ -425,7 +422,7 @@ step: plan ## Context This feature should have definition of done in its prompt. -"#; +"; tctx.ctx.create_ticket("FEAT", "FEAT-002", ticket_content); tctx.ctx.run_launch(&[]); @@ -461,14 +458,14 @@ mod session_lifecycle { let tctx = TmuxTestContext::new("kill_session"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-006 priority: P2-medium status: queued --- # Task: Test session kill -"#; +"; tctx.ctx.create_ticket("TASK", "TASK-006", ticket_content); tctx.ctx.run_launch(&[]); @@ -499,7 +496,7 @@ fn test_command_file_is_created_during_launch() { let tctx = TmuxTestContext::new("command_file"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-008 priority: P2-medium status: queued @@ -509,7 +506,7 @@ status: queued ## Context This test verifies that a command shell script is created during launch. -"#; +"; tctx.ctx.create_ticket("TASK", "TASK-008", ticket_content); tctx.ctx.run_launch(&[]); @@ -524,8 +521,8 @@ This test verifies that a command shell script is created during launch. // Verify command file structure let (path, content) = &command_files[0]; - eprintln!("Command file path: {:?}", path); - eprintln!("Command file content:\n{}", content); + eprintln!("Command file path: {path:?}"); + eprintln!("Command file content:\n{content}"); // Should have shebang assert!( @@ -537,15 +534,13 @@ This test verifies that a command shell script is created during launch. // Should have cd command assert!( content.contains("cd "), - "Command file should contain cd command. Got: {}", - content + "Command file should contain cd command. Got: {content}" ); // Should have exec command with LLM tool assert!( content.contains("exec "), - "Command file should contain exec command. Got: {}", - content + "Command file should contain exec command. Got: {content}" ); // Should be executable on Unix @@ -557,8 +552,7 @@ This test verifies that a command shell script is created during launch. let mode = permissions.mode(); assert!( mode & 0o111 != 0, - "Command file should be executable. Mode: {:o}", - mode + "Command file should be executable. Mode: {mode:o}" ); } } @@ -573,14 +567,14 @@ mod error_handling { let tctx = TmuxTestContext::new("bad_project"); // Create ticket with non-existent project - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-007 priority: P2-medium status: queued --- # Task: Test bad project -"#; +"; // Manually create with bad project name let filename = format!( "{}-TASK-nonexistent-project-task_007.md", @@ -594,8 +588,8 @@ status: queued let stdout = String::from_utf8_lossy(&output.stdout); // Should fail with project not found error - let combined = format!("{}{}", stdout, stderr); - eprintln!("Output: {}", combined); + let combined = format!("{stdout}{stderr}"); + eprintln!("Output: {combined}"); // Either no session created or error message std::thread::sleep(Duration::from_secs(1)); diff --git a/tests/launch_integration_cmux.rs b/tests/launch_integration_cmux.rs index a25097b..8ea5649 100644 --- a/tests/launch_integration_cmux.rs +++ b/tests/launch_integration_cmux.rs @@ -9,7 +9,7 @@ //! - Launcher wiring with `Launcher::with_cmux_client()` //! - Prompt file generation //! - Command file creation -//! - MockCmuxClient workspace creation +//! - `MockCmuxClient` workspace creation //! - Ticket state transitions //! //! ## Environment Variables @@ -51,7 +51,7 @@ macro_rules! skip_if_not_configured { }; } -/// Create a LaunchTestContext with cmux config and load the Config from it +/// Create a `LaunchTestContext` with cmux config and load the Config from it fn setup_cmux_test(test_name: &str) -> (LaunchTestContext, Config) { let ctx = LaunchTestContext::new(test_name, WrapperTestMode::Cmux); let config = @@ -85,7 +85,7 @@ fn test_cmux_launcher_with_mock_client() { let (_ctx, config) = setup_cmux_test("launcher_mock"); let mock = Arc::new(MockCmuxClient::new()); - let launcher = Launcher::with_cmux_client(&config, mock.clone()); + let launcher = Launcher::with_cmux_client(&config, mock); assert!( launcher.is_ok(), @@ -99,7 +99,7 @@ async fn test_cmux_launch_creates_workspace() { let (ctx, config) = setup_cmux_test("creates_workspace"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-C01 priority: P2-medium status: queued @@ -109,7 +109,7 @@ status: queued ## Context This is a test task to verify cmux workspace creation via mock client. -"#; +"; ctx.create_ticket("TASK", "TASK-C01", ticket_content); let mock = Arc::new(MockCmuxClient::new()); @@ -120,7 +120,7 @@ This is a test task to verify cmux workspace creation via mock client. let queue_dir = ctx.tickets_path.join("queue"); let ticket_file = std::fs::read_dir(&queue_dir) .unwrap() - .filter_map(|e| e.ok()) + .filter_map(std::result::Result::ok) .find(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) .expect("Should have a ticket file"); @@ -139,10 +139,7 @@ This is a test task to verify cmux workspace creation via mock client. // launch_with_options returns the agent_id (a UUID), not the session name let agent_id = result.unwrap(); - assert!( - !agent_id.is_empty(), - "Should return a non-empty agent ID" - ); + assert!(!agent_id.is_empty(), "Should return a non-empty agent ID"); // Verify ticket was moved to in-progress assert!( @@ -157,7 +154,7 @@ async fn test_cmux_prompt_file_written() { let (ctx, config) = setup_cmux_test("prompt_file"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-C02 priority: P2-medium status: queued @@ -167,7 +164,7 @@ status: queued ## Context CMUX_PROMPT_MARKER_77777 -"#; +"; ctx.create_ticket("TASK", "TASK-C02", ticket_content); let mock = Arc::new(MockCmuxClient::new()); @@ -177,7 +174,7 @@ CMUX_PROMPT_MARKER_77777 let queue_dir = ctx.tickets_path.join("queue"); let ticket_file = std::fs::read_dir(&queue_dir) .unwrap() - .filter_map(|e| e.ok()) + .filter_map(std::result::Result::ok) .find(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) .expect("Should have a ticket file"); let ticket = Ticket::from_file(&ticket_file.path()).expect("Should parse ticket"); @@ -195,8 +192,7 @@ CMUX_PROMPT_MARKER_77777 .any(|p| p.contains("TASK-C02") || p.contains("task_c02")); assert!( has_reference, - "Prompt file should reference ticket. Prompts: {:?}", - prompts + "Prompt file should reference ticket. Prompts: {prompts:?}" ); } @@ -206,14 +202,14 @@ async fn test_cmux_command_file_created() { let (ctx, config) = setup_cmux_test("command_file"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-C03 priority: P2-medium status: queued --- # Task: Test cmux command file creation -"#; +"; ctx.create_ticket("TASK", "TASK-C03", ticket_content); let mock = Arc::new(MockCmuxClient::new()); @@ -223,7 +219,7 @@ status: queued let queue_dir = ctx.tickets_path.join("queue"); let ticket_file = std::fs::read_dir(&queue_dir) .unwrap() - .filter_map(|e| e.ok()) + .filter_map(std::result::Result::ok) .find(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) .expect("Should have a ticket file"); let ticket = Ticket::from_file(&ticket_file.path()).expect("Should parse ticket"); @@ -260,14 +256,14 @@ async fn test_cmux_mock_receives_send_text() { let (ctx, config) = setup_cmux_test("send_text"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-C04 priority: P2-medium status: queued --- # Task: Test cmux send_text via mock -"#; +"; ctx.create_ticket("TASK", "TASK-C04", ticket_content); let mock = Arc::new(MockCmuxClient::new()); @@ -277,7 +273,7 @@ status: queued let queue_dir = ctx.tickets_path.join("queue"); let ticket_file = std::fs::read_dir(&queue_dir) .unwrap() - .filter_map(|e| e.ok()) + .filter_map(std::result::Result::ok) .find(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) .expect("Should have a ticket file"); let ticket = Ticket::from_file(&ticket_file.path()).expect("Should parse ticket"); @@ -299,7 +295,6 @@ status: queued let has_command = sent.iter().any(|(_ws, text)| text.contains("bash ")); assert!( has_command, - "Sent text should contain bash command. Got: {:?}", - sent + "Sent text should contain bash command. Got: {sent:?}" ); } diff --git a/tests/launch_integration_vscode.rs b/tests/launch_integration_vscode.rs index 0ab0418..ccb7f8c 100644 --- a/tests/launch_integration_vscode.rs +++ b/tests/launch_integration_vscode.rs @@ -50,7 +50,7 @@ macro_rules! skip_if_not_configured { }; } -/// Create a LaunchTestContext with vscode config +/// Create a `LaunchTestContext` with vscode config fn setup_vscode_test(test_name: &str) -> (LaunchTestContext, Config) { let ctx = LaunchTestContext::new_with_sessions_toml( test_name, @@ -69,7 +69,7 @@ fn load_ticket_from_queue(ctx: &LaunchTestContext) -> Ticket { let queue_dir = ctx.tickets_path.join("queue"); let ticket_file = std::fs::read_dir(&queue_dir) .unwrap() - .filter_map(|e| e.ok()) + .filter_map(std::result::Result::ok) .find(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) .expect("Should have a ticket file"); Ticket::from_file(&ticket_file.path()).expect("Should parse ticket") @@ -96,7 +96,7 @@ async fn test_vscode_prepare_launch_returns_prepared_launch() { let (ctx, config) = setup_vscode_test("prepare_launch"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-V01 priority: P2-medium status: queued @@ -106,7 +106,7 @@ status: queued ## Context This is a test task to verify the prepare_launch path for VS Code. -"#; +"; ctx.create_ticket("TASK", "TASK-V01", ticket_content); let launcher = Launcher::new(&config).expect("Failed to create launcher"); @@ -132,10 +132,7 @@ This is a test task to verify the prepare_launch path for VS Code. ); // Verify command is not empty - assert!( - !prepared.command.is_empty(), - "Command should not be empty" - ); + assert!(!prepared.command.is_empty(), "Command should not be empty"); // Verify session ID is a valid UUID assert!( @@ -151,10 +148,7 @@ This is a test task to verify the prepare_launch path for VS Code. ); // Verify ticket ID matches - assert_eq!( - prepared.ticket_id, "TASK-V01", - "Ticket ID should match" - ); + assert_eq!(prepared.ticket_id, "TASK-V01", "Ticket ID should match"); // Verify working directory contains testproject assert!( @@ -173,7 +167,7 @@ async fn test_vscode_prepare_launch_writes_prompt_file() { let (ctx, config) = setup_vscode_test("prompt_file"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-V02 priority: P2-medium status: queued @@ -183,7 +177,7 @@ status: queued ## Context VSCODE_PROMPT_MARKER_88888 -"#; +"; ctx.create_ticket("TASK", "TASK-V02", ticket_content); let launcher = Launcher::new(&config).expect("Failed to create launcher"); @@ -201,8 +195,7 @@ VSCODE_PROMPT_MARKER_88888 .any(|p| p.contains("TASK-V02") || p.contains("task_v02")); assert!( has_reference, - "Prompt file should reference ticket. Prompts: {:?}", - prompts + "Prompt file should reference ticket. Prompts: {prompts:?}" ); } @@ -212,14 +205,14 @@ async fn test_vscode_prepare_launch_moves_ticket() { let (ctx, config) = setup_vscode_test("ticket_state"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-V03 priority: P2-medium status: queued --- # Task: Test VS Code ticket state transition -"#; +"; ctx.create_ticket("TASK", "TASK-V03", ticket_content); let launcher = Launcher::new(&config).expect("Failed to create launcher"); @@ -241,14 +234,14 @@ async fn test_vscode_prepare_launch_command_contains_mock_llm() { let (ctx, config) = setup_vscode_test("command_content"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-V04 priority: P2-medium status: queued --- # Task: Test VS Code command content -"#; +"; ctx.create_ticket("TASK", "TASK-V04", ticket_content); let launcher = Launcher::new(&config).expect("Failed to create launcher"); diff --git a/tests/launch_integration_zellij.rs b/tests/launch_integration_zellij.rs index 2ab93e3..85e0689 100644 --- a/tests/launch_integration_zellij.rs +++ b/tests/launch_integration_zellij.rs @@ -75,7 +75,9 @@ macro_rules! skip_if_not_configured { return; } if !in_zellij() { - eprintln!("Skipping test: not running inside a zellij session (ZELLIJ env var not set)"); + eprintln!( + "Skipping test: not running inside a zellij session (ZELLIJ env var not set)" + ); return; } }; @@ -91,7 +93,7 @@ fn list_operator_tabs() -> Vec { Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout) .lines() .filter(|s| s.starts_with("op-")) - .map(|s| s.to_string()) + .map(std::string::ToString::to_string) .collect(), _ => Vec::new(), } @@ -115,7 +117,7 @@ fn cleanup_operator_tabs() { } } -/// Zellij test wrapper providing tab management on top of LaunchTestContext +/// Zellij test wrapper providing tab management on top of `LaunchTestContext` struct ZellijTestContext { ctx: LaunchTestContext, } @@ -142,7 +144,7 @@ fn test_launch_creates_zellij_tab() { let zctx = ZellijTestContext::new("creates_tab"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-Z01 priority: P2-medium status: queued @@ -152,7 +154,7 @@ status: queued ## Context This is a test task to verify zellij tab creation. -"#; +"; zctx.ctx.create_ticket("TASK", "TASK-Z01", ticket_content); // Run launch @@ -160,8 +162,8 @@ This is a test task to verify zellij tab creation. let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("stdout: {}", stdout); - eprintln!("stderr: {}", stderr); + eprintln!("stdout: {stdout}"); + eprintln!("stderr: {stderr}"); // Wait for tab to be created std::thread::sleep(Duration::from_secs(2)); @@ -170,8 +172,7 @@ This is a test task to verify zellij tab creation. let tabs = list_operator_tabs(); assert!( tabs.iter().any(|t| t.contains("TASK-Z01")), - "Zellij tab should be created. Tabs: {:?}", - tabs + "Zellij tab should be created. Tabs: {tabs:?}" ); // Verify ticket was moved to in-progress @@ -187,7 +188,7 @@ fn test_zellij_prompt_file_contains_ticket_content() { let zctx = ZellijTestContext::new("prompt_content"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-Z02 priority: P2-medium status: queued @@ -197,7 +198,7 @@ status: queued ## Context This content should appear in the prompt file: ZELLIJ_MARKER_99999 -"#; +"; zctx.ctx.create_ticket("TASK", "TASK-Z02", ticket_content); zctx.ctx.run_launch(&[]); @@ -224,14 +225,14 @@ fn test_zellij_llm_command_has_session_id_and_model() { let zctx = ZellijTestContext::new("command_structure"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-Z03 priority: P2-medium status: queued --- # Task: Test command structure in zellij -"#; +"; zctx.ctx.create_ticket("TASK", "TASK-Z03", ticket_content); zctx.ctx.run_launch(&[]); @@ -278,14 +279,14 @@ fn test_zellij_command_file_is_created() { let zctx = ZellijTestContext::new("command_file"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-Z04 priority: P2-medium status: queued --- # Task: Test command file creation in zellij -"#; +"; zctx.ctx.create_ticket("TASK", "TASK-Z04", ticket_content); zctx.ctx.run_launch(&[]); @@ -325,7 +326,7 @@ fn test_zellij_prompt_file_is_written_to_disk() { let zctx = ZellijTestContext::new("prompt_file"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-Z05 priority: P2-medium status: queued @@ -335,7 +336,7 @@ status: queued ## Context ZELLIJ_PROMPT_MARKER_54321 -"#; +"; zctx.ctx.create_ticket("TASK", "TASK-Z05", ticket_content); zctx.ctx.run_launch(&[]); @@ -349,8 +350,7 @@ ZELLIJ_PROMPT_MARKER_54321 .any(|p| p.contains("TASK-Z05") || p.contains("task_z05")); assert!( has_reference, - "Prompt file should reference ticket. Prompts: {:?}", - prompts + "Prompt file should reference ticket. Prompts: {prompts:?}" ); } @@ -360,14 +360,14 @@ fn test_zellij_ticket_moved_to_in_progress() { let zctx = ZellijTestContext::new("in_progress"); - let ticket_content = r#"--- + let ticket_content = r"--- id: TASK-Z06 priority: P2-medium status: queued --- # Task: Test ticket state transition in zellij -"#; +"; zctx.ctx.create_ticket("TASK", "TASK-Z06", ticket_content); zctx.ctx.run_launch(&[]); diff --git a/tests/opr8r_integration.rs b/tests/opr8r_integration.rs index 1ea1502..824a07b 100644 --- a/tests/opr8r_integration.rs +++ b/tests/opr8r_integration.rs @@ -65,7 +65,7 @@ fn test_opr8r_version() { skip_if_not_configured!(); let opr8r_path = get_opr8r_path(); - eprintln!("Testing opr8r at: {:?}", opr8r_path); + eprintln!("Testing opr8r at: {opr8r_path:?}"); let output = Command::new(&opr8r_path) .arg("--version") From 34870ad4eb2bd411f82e27d7be7e24169c4e68a0 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 22 Mar 2026 10:40:45 -0600 Subject: [PATCH 3/6] more clippy linting fixes more linting for the linting god Co-Authored-By: Claude Opus 4.5 --- .github/workflows/vscode-extension.yaml | 9 + Cargo.toml | 4 - src/agents/agent_switcher.rs | 3 +- src/agents/hooks/mod.rs | 2 +- src/agents/launcher/llm_command.rs | 9 +- src/agents/launcher/mod.rs | 2 +- src/agents/launcher/step_config.rs | 16 +- src/agents/monitor.rs | 2 +- src/agents/sync.rs | 2 +- src/api/anthropic.rs | 8 +- src/api/providers/ai/anthropic.rs | 2 +- src/api/providers/ai/mod.rs | 6 +- src/backstage/scaffold.rs | 5 +- src/git/worktree.rs | 6 +- src/notifications/linux.rs | 2 +- src/permissions/translator.rs | 19 +- src/queue/watcher.rs | 8 +- tests/git_integration.rs | 2 +- vscode-extension/package-lock.json | 1484 +++++++++++++++++++++-- vscode-extension/package.json | 1 + 20 files changed, 1440 insertions(+), 152 deletions(-) diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index f80e26d..327932e 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -37,6 +37,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Copy generated types + run: npm run copy-types + + - name: Generate icon font + run: npm run generate:icons + - name: Lint run: npm run lint @@ -176,6 +182,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Generate icon font + run: npm run generate:icons + - name: Package platform-specific VSIX run: npx vsce package --target ${{ matrix.vscode_target }} diff --git a/Cargo.toml b/Cargo.toml index 9013250..b81221f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,8 +122,6 @@ similar_names = "allow" struct_field_names = "allow" # String building in TUI renderers format_push_string = "allow" -# Debug formatting is intentional for error messages -unnecessary_debug_formatting = "allow" # Many functions wrap for future error paths unnecessary_wraps = "allow" # Common in async code stubs @@ -146,8 +144,6 @@ ref_option = "allow" fn_params_excessive_bools = "allow" # HashMap without explicit hasher is fine for non-perf-critical code implicit_hasher = "allow" -# lazy_static migration tracked separately -non_std_lazy_statics = "allow" # map_or readability is subjective map_unwrap_or = "allow" # for_each vs for loop is stylistic diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index 445ce58..22613f9 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -292,6 +292,7 @@ mod tests { use crate::agents::tmux::MockTmuxClient; use crate::config::{Config, Delegator}; use crate::templates::schema::{PermissionMode, ReviewType, StepSchema}; + use std::collections::HashMap; fn make_step(agent: Option<&str>) -> StepSchema { StepSchema { @@ -327,7 +328,7 @@ mod tests { llm_tool: tool.to_string(), model: model.to_string(), display_name: None, - model_properties: Default::default(), + model_properties: HashMap::default(), launch_config: None, } } diff --git a/src/agents/hooks/mod.rs b/src/agents/hooks/mod.rs index 59b65d3..526cad6 100644 --- a/src/agents/hooks/mod.rs +++ b/src/agents/hooks/mod.rs @@ -226,7 +226,7 @@ mod tests { let signal = signal.unwrap(); assert_eq!(signal.event, "stop"); assert_eq!(signal.session_id, "test-session"); - assert_eq!(signal.timestamp, 1234567890); + assert_eq!(signal.timestamp, 1_234_567_890); } #[test] diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index a758d5a..933e9b9 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -157,7 +157,7 @@ fn generate_config_flags( .join("sessions") .join(&ticket.id); fs::create_dir_all(&session_dir) - .with_context(|| format!("Failed to create session dir: {session_dir:?}"))?; + .with_context(|| format!("Failed to create session dir: {}", session_dir.display()))?; // Generate config using translator let translator = TranslatorManager::new(); @@ -209,7 +209,10 @@ fn generate_config_flags( let schema_str = serde_json::to_string_pretty(schema) .context("Failed to serialize JSON schema")?; fs::write(&schema_file_path, &schema_str).with_context(|| { - format!("Failed to write JSON schema file: {schema_file_path:?}") + format!( + "Failed to write JSON schema file: {}", + schema_file_path.display() + ) })?; cli_flags.push("--json-schema".to_string()); cli_flags.push(schema_file_path.to_string_lossy().to_string()); @@ -228,7 +231,7 @@ fn generate_config_flags( }; // Verify schema file exists, then pass the path (not content) if !schema_path.exists() { - anyhow::bail!("JSON schema file not found: {schema_path:?}"); + anyhow::bail!("JSON schema file not found: {}", schema_path.display()); } cli_flags.push("--json-schema".to_string()); cli_flags.push(schema_path.to_string_lossy().to_string()); diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 97cad8c..f8eb295 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -1001,7 +1001,7 @@ impl Launcher { }; if !project_path.exists() { - anyhow::bail!("Project path does not exist: {project_path:?}"); + anyhow::bail!("Project path does not exist: {}", project_path.display()); } Ok(project_path.to_string_lossy().to_string()) diff --git a/src/agents/launcher/step_config.rs b/src/agents/launcher/step_config.rs index 1f2bb7f..3c5b4b7 100644 --- a/src/agents/launcher/step_config.rs +++ b/src/agents/launcher/step_config.rs @@ -73,10 +73,18 @@ pub fn load_project_permissions(_config: &Config, project_path: &str) -> Result< .join("permissions.json"); if permissions_path.exists() { - let content = fs::read_to_string(&permissions_path) - .with_context(|| format!("Failed to read permissions file: {permissions_path:?}"))?; - let proj_perms: ProjectPermissions = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse permissions file: {permissions_path:?}"))?; + let content = fs::read_to_string(&permissions_path).with_context(|| { + format!( + "Failed to read permissions file: {}", + permissions_path.display() + ) + })?; + let proj_perms: ProjectPermissions = serde_json::from_str(&content).with_context(|| { + format!( + "Failed to parse permissions file: {}", + permissions_path.display() + ) + })?; Ok(proj_perms.base) } else { // No project permissions file, use empty defaults diff --git a/src/agents/monitor.rs b/src/agents/monitor.rs index 3e0d043..619ee02 100644 --- a/src/agents/monitor.rs +++ b/src/agents/monitor.rs @@ -965,7 +965,7 @@ mod tests { std::fs::create_dir_all(&signal_dir).unwrap(); let signal = HookSignal { event: "stop".to_string(), - timestamp: 1234567890, + timestamp: 1_234_567_890, session_id: agent_id.to_string(), }; let signal_path = signal_dir.join(format!("{agent_id}.signal")); diff --git a/src/agents/sync.rs b/src/agents/sync.rs index 657e73c..efa0430 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -595,7 +595,7 @@ impl TicketSessionSync { }; if !project_path.exists() { - anyhow::bail!("Project path does not exist: {project_path:?}"); + anyhow::bail!("Project path does not exist: {}", project_path.display()); } Ok(project_path) diff --git a/src/api/anthropic.rs b/src/api/anthropic.rs index 0583eb3..4233b0f 100644 --- a/src/api/anthropic.rs +++ b/src/api/anthropic.rs @@ -252,14 +252,14 @@ mod tests { #[test] fn test_rate_limit_info_summary() { let info = RateLimitInfo { - input_tokens_limit: Some(100000), + input_tokens_limit: Some(100_000), input_tokens_remaining: Some(87000), ..Default::default() }; assert_eq!(info.summary(), "87% input"); let info = RateLimitInfo { - tokens_limit: Some(100000), + tokens_limit: Some(100_000), tokens_remaining: Some(45000), ..Default::default() }; @@ -275,7 +275,7 @@ mod tests { #[test] fn test_is_below_threshold() { let info = RateLimitInfo { - input_tokens_limit: Some(100000), + input_tokens_limit: Some(100_000), input_tokens_remaining: Some(15000), // 15% ..Default::default() }; @@ -286,7 +286,7 @@ mod tests { #[test] fn test_tokens_remaining_pct() { let info = RateLimitInfo { - tokens_limit: Some(100000), + tokens_limit: Some(100_000), tokens_remaining: Some(50000), ..Default::default() }; diff --git a/src/api/providers/ai/anthropic.rs b/src/api/providers/ai/anthropic.rs index 66646b7..cf61e39 100644 --- a/src/api/providers/ai/anthropic.rs +++ b/src/api/providers/ai/anthropic.rs @@ -243,7 +243,7 @@ mod tests { let info = AnthropicProvider::parse_rate_limit_headers(&headers); assert_eq!(info.provider, "anthropic"); - assert_eq!(info.tokens_limit, Some(100000)); + assert_eq!(info.tokens_limit, Some(100_000)); assert_eq!(info.tokens_remaining, Some(75000)); assert_eq!(info.input_tokens_limit, Some(50000)); assert_eq!(info.input_tokens_remaining, Some(40000)); diff --git a/src/api/providers/ai/mod.rs b/src/api/providers/ai/mod.rs index 5dcfa1d..23a174a 100644 --- a/src/api/providers/ai/mod.rs +++ b/src/api/providers/ai/mod.rs @@ -155,12 +155,12 @@ mod tests { #[test] fn test_rate_limit_info_summary() { let mut info = RateLimitInfo::new("anthropic"); - info.input_tokens_limit = Some(100000); + info.input_tokens_limit = Some(100_000); info.input_tokens_remaining = Some(87000); assert_eq!(info.summary(), "87% input"); let mut info = RateLimitInfo::new("openai"); - info.tokens_limit = Some(100000); + info.tokens_limit = Some(100_000); info.tokens_remaining = Some(45000); assert_eq!(info.summary(), "45% tokens"); @@ -173,7 +173,7 @@ mod tests { #[test] fn test_is_below_threshold() { let mut info = RateLimitInfo::new("anthropic"); - info.input_tokens_limit = Some(100000); + info.input_tokens_limit = Some(100_000); info.input_tokens_remaining = Some(15000); // 15% assert!(info.is_below_threshold(0.2)); // Below 20% diff --git a/src/backstage/scaffold.rs b/src/backstage/scaffold.rs index da74819..8fb7105 100644 --- a/src/backstage/scaffold.rs +++ b/src/backstage/scaffold.rs @@ -831,8 +831,9 @@ impl BackstageScaffold { let mut result = ScaffoldResult::new(self.output_dir.clone()); // Create output directory - fs::create_dir_all(&self.output_dir) - .with_context(|| format!("Failed to create directory: {:?}", self.output_dir))?; + fs::create_dir_all(&self.output_dir).with_context(|| { + format!("Failed to create directory: {}", self.output_dir.display()) + })?; for generator in self.generators() { // Check if this file should be created diff --git a/src/git/worktree.rs b/src/git/worktree.rs index 2897efb..42fe593 100644 --- a/src/git/worktree.rs +++ b/src/git/worktree.rs @@ -15,10 +15,8 @@ use tokio::sync::Mutex; use tracing::{debug, info, instrument, warn}; // Global locks for worktree creation (prevent race conditions) -lazy_static::lazy_static! { - static ref WORKTREE_CREATION_LOCKS: Mutex>>> = - Mutex::new(HashMap::new()); -} +static WORKTREE_CREATION_LOCKS: std::sync::LazyLock>>>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); /// Get or create a lock for a specific path async fn get_path_lock(path: &Path) -> Arc> { diff --git a/src/notifications/linux.rs b/src/notifications/linux.rs index a00e69e..e0933a9 100644 --- a/src/notifications/linux.rs +++ b/src/notifications/linux.rs @@ -6,7 +6,7 @@ pub fn send_notification(title: &str, subtitle: &str, message: &str, _sound: boo let body = if subtitle.is_empty() { message.to_string() } else { - format!("{}\n{}", subtitle, message) + format!("{subtitle}\n{message}") }; // Handle D-Bus errors gracefully - notification daemon may not be available diff --git a/src/permissions/translator.rs b/src/permissions/translator.rs index 3e29c84..11e89a7 100644 --- a/src/permissions/translator.rs +++ b/src/permissions/translator.rs @@ -110,12 +110,14 @@ impl TranslatorManager { // Ensure parent directories exist if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create config dir: {parent:?}"))?; + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create config dir: {}", parent.display()) + })?; } - fs::write(&full_path, &content) - .with_context(|| format!("Failed to write config file: {full_path:?}"))?; + fs::write(&full_path, &content).with_context(|| { + format!("Failed to write config file: {}", full_path.display()) + })?; // Add CLI flag to point to config directory match provider { @@ -158,17 +160,18 @@ impl TranslatorManager { full_command: &str, ) -> Result<()> { fs::create_dir_all(session_dir) - .with_context(|| format!("Failed to create session dir: {session_dir:?}"))?; + .with_context(|| format!("Failed to create session dir: {}", session_dir.display()))?; // Save launch command let command_path = session_dir.join("launch-command.txt"); - fs::write(&command_path, full_command) - .with_context(|| format!("Failed to write launch command: {command_path:?}"))?; + fs::write(&command_path, full_command).with_context(|| { + format!("Failed to write launch command: {}", command_path.display()) + })?; // Save audit info let audit_path = session_dir.join(format!("{provider}-audit.txt")); fs::write(&audit_path, &config.audit_info) - .with_context(|| format!("Failed to write audit info: {audit_path:?}"))?; + .with_context(|| format!("Failed to write audit info: {}", audit_path.display()))?; Ok(()) } diff --git a/src/queue/watcher.rs b/src/queue/watcher.rs index 6d3b525..6660717 100644 --- a/src/queue/watcher.rs +++ b/src/queue/watcher.rs @@ -86,7 +86,9 @@ impl QueueWatcher { #[cfg(test)] mod tests { use super::*; - use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind, RemoveKind}; + use notify::event::{ + AccessKind, CreateKind, DataChange, EventAttributes, ModifyKind, RemoveKind, + }; use notify::EventKind; use std::path::PathBuf; use tempfile::TempDir; @@ -108,7 +110,7 @@ mod tests { Event { kind, paths: vec![path], - attrs: Default::default(), + attrs: EventAttributes::default(), } } @@ -187,7 +189,7 @@ mod tests { let event = Event { kind: EventKind::Create(CreateKind::File), paths: vec![], - attrs: Default::default(), + attrs: EventAttributes::default(), }; let result = watcher.classify_event(event); diff --git a/tests/git_integration.rs b/tests/git_integration.rs index 866aa22..8083e8b 100644 --- a/tests/git_integration.rs +++ b/tests/git_integration.rs @@ -90,7 +90,7 @@ fn test_ticket_id() -> String { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis(); - format!("OPTEST-{}", timestamp % 100000) + format!("OPTEST-{}", timestamp % 100_000) } /// Get the path to the operator repository (current working directory) diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index e40a30a..e6e27fa 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -35,6 +35,7 @@ "c8": "^10.1.2", "css-loader": "^7.1.0", "eslint": "^8.56.0", + "fantasticon": "^4.1.0", "glob": "^10.3.10", "mocha": "^10.2.0", "nyc": "^17.1.0", @@ -885,6 +886,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1348,6 +1362,43 @@ "node": ">= 8" } }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1752,6 +1803,16 @@ "@types/react": "*" } }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -2533,6 +2594,17 @@ } } }, + "node_modules/@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "deprecated": "this version is no longer supported, please update to at least 0.8.*", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -2547,6 +2619,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2835,6 +2917,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -2985,6 +3077,35 @@ "dev": true, "license": "MIT" }, + "node_modules/bufferstreams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-4.0.0.tgz", + "integrity": "sha512-azX778/2VQ9K2uiYprSUKLgK2K6lR1KtJycJDsMg7u0+Cc994A9HyGaUKb01e/T+M8jse057429iKXurCaT35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0", + "yerror": "^8.0.0" + }, + "engines": { + "node": ">=20.11.1" + } + }, + "node_modules/bufferstreams/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -3045,6 +3166,50 @@ "node": ">=12" } }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cacache/node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -3161,6 +3326,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/case": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/case/-/case-1.6.3.tgz", + "integrity": "sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -3286,6 +3461,23 @@ "node": ">=6" } }, + "node_modules/cli-color": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.4.tgz", + "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.64", + "es6-iterator": "^2.0.3", + "memoizee": "^0.4.15", + "timers-ext": "^0.1.7" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3630,6 +3822,27 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cubic2quad": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cubic2quad/-/cubic2quad-1.2.1.tgz", + "integrity": "sha512-wT5Y7mO8abrV16gnssKdmIhIbA9wSkeMzhh27jAguKrV82i24wER0vL5TGhUJ9dbJNDcigoRZ0IAHFEEEI4THQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "dev": true, + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3926,6 +4139,17 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -3978,6 +4202,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/envinfo": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", @@ -3991,6 +4225,13 @@ "node": ">=4" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4056,6 +4297,23 @@ "node": ">= 0.4" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "dev": true, + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -4063,6 +4321,45 @@ "dev": true, "license": "MIT" }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dev": true, + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4296,6 +4593,22 @@ "node": ">=8" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -4374,6 +4687,17 @@ "node": ">=0.10.0" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -4395,75 +4719,212 @@ "node": ">=6" } }, - "node_modules/fast-deep-equal": { + "node_modules/exponential-backoff": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "dev": true, - "license": "MIT" + "license": "Apache-2.0" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dev": true, + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/fantasticon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/fantasticon/-/fantasticon-4.1.0.tgz", + "integrity": "sha512-Clnp+ic3eH33fEKfJOf7eDOUAjv/+cD7lqPQVMwDk+5jYVzNoBrQi1JVSSsSL+j1+S04bQDHH35RkpnsvBQILA==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "case": "^1.6.3", + "cli-color": "^2.0.4", + "commander": "^14.0.2", + "glob": "^13.0.0", + "handlebars": "^4.7.8", + "slugify": "^1.6.6", + "svg2ttf": "^6.0.3", + "svgicons2svgfont": "^15.0.1", + "ttf2eot": "^3.1.0", + "ttf2woff": "^3.0.0", + "ttf2woff2": "^8.0.0" + }, + "bin": { + "fantasticon": "bin/fantasticon" }, "engines": { - "node": ">=8.6.0" + "node": ">= 22.0" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "node_modules/fantasticon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4.9.1" + "node": "18 || 20 || >=22" } }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "node_modules/fantasticon/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/fantasticon/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/fantasticon/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fantasticon/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/fantasticon/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fantasticon/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -4493,6 +4954,13 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4668,6 +5136,19 @@ "license": "MIT", "optional": true }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4910,6 +5391,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -5066,6 +5569,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5252,6 +5762,16 @@ "node": ">=10.13.0" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -5410,6 +5930,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5932,6 +6459,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -6113,6 +6647,16 @@ "node": ">=10" } }, + "node_modules/lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es5-ext": "~0.10.2" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -6129,6 +6673,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/markdown-it": { "version": "12.3.2", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", @@ -6173,6 +6740,26 @@ "dev": true, "license": "MIT" }, + "node_modules/memoizee": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", + "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "es5-ext": "^0.10.64", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6190,6 +6777,13 @@ "node": ">= 8" } }, + "node_modules/microbuffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/microbuffer/-/microbuffer-1.0.0.tgz", + "integrity": "sha512-O/SUXauVN4x6RaEJFqSPcXNtLFL+QzJHKZlyDVYFwcDDRVca3Fa/37QXXC+4zAGGa4YhHrHxKXuuHvLDIQECtA==", + "dev": true, + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6289,85 +6883,206 @@ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/mocha": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", - "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "ansi-colors": "^4.1.3", - "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", - "debug": "^4.3.5", - "diff": "^5.2.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^8.1.0", - "he": "^1.2.0", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", - "ms": "^2.1.3", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" + "minipass": "^7.0.3" }, "engines": { - "node": ">= 14.0.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/mocha/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" }, "engines": { - "node": ">=8" + "node": "^18.17.0 || >=20.5.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "optionalDependencies": { + "encoding": "^0.1.13" } }, - "node_modules/mocha/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6541,6 +7256,13 @@ "dev": true, "license": "ISC" }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6575,6 +7297,16 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -6582,6 +7314,13 @@ "dev": true, "license": "MIT" }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "dev": true, + "license": "ISC" + }, "node_modules/nise": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", @@ -6618,6 +7357,57 @@ "license": "MIT", "optional": true }, + "node_modules/node-gyp": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", + "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -6638,6 +7428,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7741,6 +8547,16 @@ "node": ">= 0.8.0" } }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -7761,6 +8577,20 @@ "node": ">=8" } }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8093,6 +8923,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8571,6 +9411,27 @@ "node": ">=8" } }, + "node_modules/slugify": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz", + "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/smol-toml": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", @@ -8583,6 +9444,36 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8686,6 +9577,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -8884,6 +9788,194 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-7.2.0.tgz", + "integrity": "sha512-qd+AxqMpfRrRQaWb2SrNFvn69cvl6piqY8TxhYl2Li1g4/LO5F9NJb5wI4vNwRryqgSgD43gYKLm/w3ag1bKvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.11.1" + } + }, + "node_modules/svg2ttf": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg2ttf/-/svg2ttf-6.0.3.tgz", + "integrity": "sha512-CgqMyZrbOPpc+WqH7aga4JWkDPso23EgypLsbQ6gN3uoPWwwiLjXvzgrwGADBExvCRJrWFzAeK1bSoSpE7ixSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.7.2", + "argparse": "^2.0.1", + "cubic2quad": "^1.2.1", + "lodash": "^4.17.10", + "microbuffer": "^1.0.0", + "svgpath": "^2.1.5" + }, + "bin": { + "svg2ttf": "svg2ttf.js" + } + }, + "node_modules/svgicons2svgfont": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/svgicons2svgfont/-/svgicons2svgfont-15.0.1.tgz", + "integrity": "sha512-rE3BoIipD6DxBejPswalKRZZYA+7sy4miHqiHgXB0zI1xJD3gSCVrXh2R6Sdh9E4XDTxYp7gDxGW2W8DIBif/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sax": "^1.2.7", + "commander": "^12.1.0", + "debug": "^4.3.6", + "glob": "^11.0.0", + "sax": "^1.4.1", + "svg-pathdata": "^7.0.0", + "transformation-matrix": "^3.0.0", + "yerror": "^8.0.0" + }, + "bin": { + "svgicons2svgfont": "bin/svgicons2svgfont.js" + }, + "engines": { + "node": ">=20.11.1" + } + }, + "node_modules/svgicons2svgfont/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/svgicons2svgfont/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/svgicons2svgfont/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/svgicons2svgfont/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/svgicons2svgfont/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/svgicons2svgfont/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/svgicons2svgfont/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/svgicons2svgfont/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/svgicons2svgfont/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/svgpath": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/svgpath/-/svgpath-2.6.0.tgz", + "integrity": "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/fontello/svg2ttf?sponsor=1" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -8898,6 +9990,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.12", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", + "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -8946,6 +10055,26 @@ "node": ">= 6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -9045,6 +10174,20 @@ "dev": true, "license": "MIT" }, + "node_modules/timers-ext": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", + "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", + "dev": true, + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9116,6 +10259,16 @@ "node": ">=8.0" } }, + "node_modules/transformation-matrix": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/transformation-matrix/-/transformation-matrix-3.1.0.tgz", + "integrity": "sha512-oYubRWTi2tYFHAL2J8DLvPIqIYcYZ0fSOi2vmSy042Ho4jBW2ce6VP7QfD44t65WQz6bw5w1Pk22J7lcUpaTKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/chrvadala" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -9243,6 +10396,55 @@ "dev": true, "license": "0BSD" }, + "node_modules/ttf2eot": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ttf2eot/-/ttf2eot-3.1.0.tgz", + "integrity": "sha512-aHTbcYosNHVqb2Qtt9Xfta77ae/5y0VfdwNLUS6sGBeGr22cX2JDMo/i5h3uuOf+FAD3akYOr17+fYd5NK8aXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "ttf2eot": "ttf2eot.js" + } + }, + "node_modules/ttf2woff": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ttf2woff/-/ttf2woff-3.0.0.tgz", + "integrity": "sha512-OvmFcj70PhmAsVQKfC15XoKH55cRWuaRzvr2fpTNhTNer6JBpG8n6vOhRrIgxMjcikyYt88xqYXMMVapJ4Rjvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "pako": "^1.0.0" + }, + "bin": { + "ttf2woff": "ttf2woff.js" + } + }, + "node_modules/ttf2woff2": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ttf2woff2/-/ttf2woff2-8.0.1.tgz", + "integrity": "sha512-nWSZLaXOgYtvgY6G0SFI8dVHsGWIchlnNMNRglT3Amp2WGy0GSPd9kLAkFd+HvEOzZ/aY6EUrpOF66QaPbipgg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "bufferstreams": "^4.0.0", + "debug": "^4.4.1", + "nan": "^2.22.2", + "node-gyp": "^11.2.0", + "yerror": "^8.0.0" + }, + "bin": { + "ttf2woff2": "bin/ttf2woff2.js" + }, + "engines": { + "node": ">=20.11.1" + } + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -9267,6 +10469,13 @@ "node": "*" } }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "dev": true, + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9346,6 +10555,20 @@ "dev": true, "license": "MIT" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/underscore": { "version": "1.13.7", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", @@ -9370,6 +10593,32 @@ "dev": true, "license": "MIT" }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9686,6 +10935,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/workerpool": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", @@ -10021,6 +11277,16 @@ "buffer-crc32": "~0.2.3" } }, + "node_modules/yerror": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/yerror/-/yerror-8.0.0.tgz", + "integrity": "sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.16.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/vscode-extension/package.json b/vscode-extension/package.json index a28c51b..d0fc8c8 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -422,6 +422,7 @@ "c8": "^10.1.2", "css-loader": "^7.1.0", "eslint": "^8.56.0", + "fantasticon": "^4.1.0", "glob": "^10.3.10", "mocha": "^10.2.0", "nyc": "^17.1.0", From 5bad57b42d90639c6d8869eb64c9ab53ddeb34e7 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 22 Mar 2026 10:52:06 -0600 Subject: [PATCH 4/6] connection nuances are better, improve vscode sidebar icon adjustment, docs coordination, sidebar adjustment, cmux and zellij polish Co-Authored-By: Claude Opus 4.5 --- .github/workflows/vscode-extension.yaml | 6 +- README.md | 11 +- bindings/ActiveAgentResponse.ts | 18 +- bindings/LaunchTicketRequest.ts | 8 +- bindings/LaunchTicketResponse.ts | 4 + bindings/LlmToolsResponse.ts | 15 + bindings/VsCodeLaunchOptions.ts | 6 +- docs/_data/navigation.yml | 45 +- docs/_includes/sidebar.html | 2 +- docs/assets/css/main.css | 19 +- docs/assets/icons/claude.svg | 3 + docs/assets/icons/cmux.svg | 3 + docs/assets/icons/codex-dark.svg | 4 - docs/assets/icons/codex-light.svg | 4 - docs/assets/icons/codex.svg | 3 + docs/assets/icons/gemini-dark.svg | 11 - docs/assets/icons/gemini-light.svg | 11 - docs/assets/icons/gemini.svg | 3 + docs/assets/icons/github-dark.svg | 3 - docs/assets/icons/github-light.svg | 3 - docs/assets/icons/github.svg | 3 + docs/assets/icons/gitlab.svg | 3 + docs/assets/icons/jira-dark.svg | 10 - docs/assets/icons/jira-light.svg | 10 - docs/assets/icons/jira.svg | 3 + docs/assets/icons/linear-dark.svg | 4 - docs/assets/icons/linear-light.svg | 4 - docs/assets/icons/linear.svg | 3 + docs/assets/icons/notification-dark.svg | 3 - docs/assets/icons/notification-light.svg | 3 - docs/assets/icons/notification.svg | 3 + docs/assets/icons/tmux-dark.svg | 3 - docs/assets/icons/tmux-light.svg | 3 - docs/assets/icons/tmux.svg | 3 + docs/assets/icons/vscode-dark.svg | 3 - docs/assets/icons/vscode-light.svg | 3 - docs/assets/icons/vscode.svg | 3 + docs/assets/icons/webhook-dark.svg | 3 - docs/assets/icons/webhook-light.svg | 3 - docs/assets/icons/webhook.svg | 3 + docs/assets/icons/zellij.svg | 3 + docs/getting-started/git/gitlab.md | 2 +- docs/getting-started/sessions/zellij.md | 4 +- .../images/icons => icons}/atlassian.svg | 2 +- .../images/icons => icons}/claude.svg | 2 +- icons/cmux.svg | 1 + .../images/icons => icons}/codex.svg | 2 +- .../images/icons => icons}/gemini.svg | 2 +- icons/github.svg | 1 + icons/gitlab.svg | 1 + .../images/icons => icons}/linear.svg | 2 +- icons/notification.svg | 1 + icons/tmux.svg | 1 + icons/vscode.svg | 1 + icons/webhook.svg | 1 + icons/zellij.svg | 1 + src/agents/cmux.rs | 123 +- src/agents/launcher/mod.rs | 26 + src/agents/launcher/options.rs | 4 + src/agents/launcher/zellij_session.rs | 16 +- src/agents/terminal_wrapper.rs | 19 + src/agents/vscode_types.rs | 5 +- src/app/agents.rs | 112 +- src/app/keyboard.rs | 12 +- src/app/mod.rs | 6 +- src/config.rs | 52 +- src/docs_gen/shortcuts.rs | 2 +- src/rest/dto.rs | 40 +- src/rest/mod.rs | 2 + src/rest/routes/agents.rs | 4 + src/rest/routes/launch.rs | 105 +- src/rest/routes/llm_tools.rs | 68 ++ src/rest/routes/mod.rs | 1 + src/schemas/issuetype_schema.json | 8 + src/templates/schema.rs | 3 + src/ui/dashboard.rs | 76 ++ src/ui/dialogs/confirm.rs | 58 +- src/ui/dialogs/help.rs | 38 +- src/ui/dialogs/mod.rs | 4 +- src/ui/keybindings.rs | 23 +- src/ui/panels.rs | 120 +- src/ui/session_preview.rs | 88 +- src/ui/setup/steps/wrapper.rs | 12 +- vscode-extension/.fantasticonrc.js | 2 +- .../images/icons/dist/operator-icons.css | 55 + .../images/icons/dist/operator-icons.json | 15 + .../images/icons/dist/operator-icons.woff | Bin 0 -> 3120 bytes vscode-extension/package.json | 86 +- vscode-extension/src/api-client.ts | 1 + vscode-extension/src/config-panel.ts | 11 + vscode-extension/src/extension.ts | 91 +- vscode-extension/src/git-onboarding.ts | 293 +++++ vscode-extension/src/issuetype-service.ts | 6 +- vscode-extension/src/kanban-onboarding.ts | 8 +- vscode-extension/src/launch-dialog.ts | 164 ++- vscode-extension/src/launch-manager.ts | 5 +- vscode-extension/src/mcp-connect.ts | 57 +- .../src/sections/config-section.ts | 123 ++ .../src/sections/connections-section.ts | 288 +++++ .../src/sections/delegator-section.ts | 100 ++ vscode-extension/src/sections/git-section.ts | 150 +++ vscode-extension/src/sections/index.ts | 21 + .../src/sections/issuetype-section.ts | 94 ++ .../src/sections/kanban-section.ts | 233 ++++ vscode-extension/src/sections/llm-section.ts | 192 +++ .../src/sections/managed-projects-section.ts | 79 ++ vscode-extension/src/sections/types.ts | 107 ++ vscode-extension/src/status-item.ts | 52 + vscode-extension/src/status-provider.ts | 1036 ++--------------- vscode-extension/src/webhook-server.ts | 8 + .../test/suite/api-client.test.ts | 4 + .../test/suite/mcp-connect.test.ts | 77 +- .../test/suite/status-provider.test.ts | 473 ++++++++ .../sections/PrimaryConfigSection.tsx | 9 +- 114 files changed, 3808 insertions(+), 1344 deletions(-) create mode 100644 bindings/LlmToolsResponse.ts create mode 100644 docs/assets/icons/claude.svg create mode 100644 docs/assets/icons/cmux.svg delete mode 100644 docs/assets/icons/codex-dark.svg delete mode 100644 docs/assets/icons/codex-light.svg create mode 100644 docs/assets/icons/codex.svg delete mode 100644 docs/assets/icons/gemini-dark.svg delete mode 100644 docs/assets/icons/gemini-light.svg create mode 100644 docs/assets/icons/gemini.svg delete mode 100644 docs/assets/icons/github-dark.svg delete mode 100644 docs/assets/icons/github-light.svg create mode 100644 docs/assets/icons/github.svg create mode 100644 docs/assets/icons/gitlab.svg delete mode 100644 docs/assets/icons/jira-dark.svg delete mode 100644 docs/assets/icons/jira-light.svg create mode 100644 docs/assets/icons/jira.svg delete mode 100644 docs/assets/icons/linear-dark.svg delete mode 100644 docs/assets/icons/linear-light.svg create mode 100644 docs/assets/icons/linear.svg delete mode 100644 docs/assets/icons/notification-dark.svg delete mode 100644 docs/assets/icons/notification-light.svg create mode 100644 docs/assets/icons/notification.svg delete mode 100644 docs/assets/icons/tmux-dark.svg delete mode 100644 docs/assets/icons/tmux-light.svg create mode 100644 docs/assets/icons/tmux.svg delete mode 100644 docs/assets/icons/vscode-dark.svg delete mode 100644 docs/assets/icons/vscode-light.svg create mode 100644 docs/assets/icons/vscode.svg delete mode 100644 docs/assets/icons/webhook-dark.svg delete mode 100644 docs/assets/icons/webhook-light.svg create mode 100644 docs/assets/icons/webhook.svg create mode 100644 docs/assets/icons/zellij.svg rename {vscode-extension/images/icons => icons}/atlassian.svg (84%) rename {vscode-extension/images/icons => icons}/claude.svg (99%) create mode 100644 icons/cmux.svg rename {vscode-extension/images/icons => icons}/codex.svg (97%) rename {vscode-extension/images/icons => icons}/gemini.svg (95%) create mode 100644 icons/github.svg create mode 100644 icons/gitlab.svg rename {vscode-extension/images/icons => icons}/linear.svg (98%) create mode 100644 icons/notification.svg create mode 100644 icons/tmux.svg create mode 100644 icons/vscode.svg create mode 100644 icons/webhook.svg create mode 100644 icons/zellij.svg create mode 100644 src/rest/routes/llm_tools.rs create mode 100644 vscode-extension/images/icons/dist/operator-icons.css create mode 100644 vscode-extension/images/icons/dist/operator-icons.json create mode 100644 vscode-extension/images/icons/dist/operator-icons.woff create mode 100644 vscode-extension/src/git-onboarding.ts create mode 100644 vscode-extension/src/sections/config-section.ts create mode 100644 vscode-extension/src/sections/connections-section.ts create mode 100644 vscode-extension/src/sections/delegator-section.ts create mode 100644 vscode-extension/src/sections/git-section.ts create mode 100644 vscode-extension/src/sections/index.ts create mode 100644 vscode-extension/src/sections/issuetype-section.ts create mode 100644 vscode-extension/src/sections/kanban-section.ts create mode 100644 vscode-extension/src/sections/llm-section.ts create mode 100644 vscode-extension/src/sections/managed-projects-section.ts create mode 100644 vscode-extension/src/sections/types.ts create mode 100644 vscode-extension/src/status-item.ts create mode 100644 vscode-extension/test/suite/status-provider.test.ts diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index 327932e..81dfe69 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -5,11 +5,13 @@ on: branches: [main] paths: - 'vscode-extension/**' + - 'icons/**' - '.github/workflows/vscode-extension.yaml' pull_request: branches: [main] paths: - 'vscode-extension/**' + - 'icons/**' workflow_dispatch: inputs: publish: @@ -41,7 +43,7 @@ jobs: run: npm run copy-types - name: Generate icon font - run: npm run generate:icons + run: mkdir -p images/icons/dist && npm run generate:icons - name: Lint run: npm run lint @@ -183,7 +185,7 @@ jobs: run: npm ci - name: Generate icon font - run: npm run generate:icons + run: mkdir -p images/icons/dist && npm run generate:icons - name: Package platform-specific VSIX run: npx vsce package --target ${{ matrix.vscode_target }} diff --git a/README.md b/README.md index d1824ae..d2dd2e9 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,17 @@ # Operator! [![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) [![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/untra.operator-terminals?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=untra.operator-terminals) +**Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) [![VS Code](https://img.shields.io/badge/VS_Code-007ACC?logo=visualstudiocode&logoColor=white)](https://operator.untra.io/getting-started/sessions/vscode/) **|** **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) **|** **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) **|** **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development. Install Operator! Terminals extension from Visual Studio Code Marketplace -**Operator** is for you if: +**Operator** is for you if: -- you do work assigned from tickets on a kanban board , such as [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/) or [_Linear_](https://operator.untra.io/getting-started/kanban/linear/) -- you use llm assisted coding agent tools to accomplish work, such as [_Claude Code_](https://operator.untra.io/getting-started/agents/claude/), [_OpenAI Codex_](https://operator.untra.io/getting-started/agents/codex/) -- your work is version controlled with git repository provider (like [_Github_](https://operator.untra.io/getting-started/git/github/)) +- you do work assigned from tickets on a kanban board, such as [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/) or [_Linear_](https://operator.untra.io/getting-started/kanban/linear/) +- you use LLM assisted coding agent tools to accomplish work, such as [_Claude Code_](https://operator.untra.io/getting-started/agents/claude/), [_OpenAI Codex_](https://operator.untra.io/getting-started/agents/codex/), or [_Gemini CLI_](https://operator.untra.io/getting-started/agents/gemini-cli/) +- your work is version controlled with a git repository provider like [_GitHub_](https://operator.untra.io/getting-started/git/github/) or [_GitLab_](https://operator.untra.io/getting-started/git/gitlab/) - you are drowning in the AI software development soup. @@ -20,7 +21,7 @@ and you are ready to start seriously automating your work. ## Overview -`operator` is a TUI (terminal user interface) application that uses [Tmux](https://github.com/tmux/tmux/wiki) to manages multiple Claude Code agents across multi-project workspaces of many codebases. It is designed to be ticket-first, starting claude code keyed off from markdown stories from a ticketing provider. It provides: +`operator` is a TUI (terminal user interface) application that uses session wrappers ([tmux](https://operator.untra.io/getting-started/sessions/tmux/), [cmux](https://operator.untra.io/getting-started/sessions/cmux/), [Zellij](https://operator.untra.io/getting-started/sessions/zellij/), or [VS Code](https://operator.untra.io/getting-started/sessions/vscode/)) to manage multiple AI coding agents across multi-project workspaces of many codebases. It is designed to be ticket-first, starting claude code keyed off from markdown stories from a ticketing provider. It provides: - **Queue Management**: ticket queue with priority-based work assignment, launchable from a dashboard - **Agent Orchestration**: Launch, monitor, pause/resume Claude Desktop agents against kanban shaped work tickets, and track the ticket progress as it goes through your defined work implementation steps diff --git a/bindings/ActiveAgentResponse.ts b/bindings/ActiveAgentResponse.ts index 8a0de77..91c5820 100644 --- a/bindings/ActiveAgentResponse.ts +++ b/bindings/ActiveAgentResponse.ts @@ -35,4 +35,20 @@ started_at: string, /** * Current workflow step */ -current_step: string | null, }; +current_step: string | null, +/** + * Which session wrapper is in use: "tmux", "vscode", "cmux", or "zellij" + */ +session_wrapper: string | null, +/** + * Session window reference ID (e.g. cmux window, tmux session) + */ +session_window_ref: string | null, +/** + * Session context reference (e.g. cmux workspace, zellij session) + */ +session_context_ref: string | null, +/** + * Session pane reference (e.g. cmux surface, zellij pane) + */ +session_pane_ref: string | null, }; diff --git a/bindings/LaunchTicketRequest.ts b/bindings/LaunchTicketRequest.ts index 3e24017..44de749 100644 --- a/bindings/LaunchTicketRequest.ts +++ b/bindings/LaunchTicketRequest.ts @@ -5,11 +5,15 @@ */ export type LaunchTicketRequest = { /** - * LLM provider to use (e.g., "claude") + * Named delegator to use (takes precedence over provider/model) + */ +delegator: string | null, +/** + * LLM provider to use (e.g., "claude") — legacy fallback when no delegator */ provider: string | null, /** - * Model to use (e.g., "sonnet", "opus") + * Model to use (e.g., "sonnet", "opus") — legacy fallback when no delegator */ model: string | null, /** diff --git a/bindings/LaunchTicketResponse.ts b/bindings/LaunchTicketResponse.ts index d59cbd4..8ef6fa7 100644 --- a/bindings/LaunchTicketResponse.ts +++ b/bindings/LaunchTicketResponse.ts @@ -36,6 +36,10 @@ session_wrapper: string | null, * Session window reference ID (e.g. cmux window, tmux session) */ session_window_ref: string | null, +/** + * Session context reference (e.g. cmux workspace, zellij session) + */ +session_context_ref: string | null, /** * Session UUID for the LLM tool */ diff --git a/bindings/LlmToolsResponse.ts b/bindings/LlmToolsResponse.ts new file mode 100644 index 0000000..bcbcdd4 --- /dev/null +++ b/bindings/LlmToolsResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DetectedTool } from "./DetectedTool"; + +/** + * Response listing detected LLM tools + */ +export type LlmToolsResponse = { +/** + * Detected CLI tools with model aliases and capabilities + */ +tools: Array, +/** + * Total count + */ +total: number, }; diff --git a/bindings/VsCodeLaunchOptions.ts b/bindings/VsCodeLaunchOptions.ts index ef011df..5e9553f 100644 --- a/bindings/VsCodeLaunchOptions.ts +++ b/bindings/VsCodeLaunchOptions.ts @@ -6,7 +6,11 @@ import type { VsCodeModelOption } from "./VsCodeModelOption"; */ export type VsCodeLaunchOptions = { /** - * Model to use (sonnet, opus, haiku) + * Named delegator to use (takes precedence over model) + */ +delegator: string | null, +/** + * Model to use (sonnet, opus, haiku) — fallback when no delegator */ model: VsCodeModelOption, /** diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 96d3bf4..3c16342 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -7,6 +7,30 @@ docs: url: /getting-started/prerequisites/ - title: Installation url: /getting-started/installation/ + - title: Supported Session Management + url: /getting-started/sessions/ + children: + - title: tmux + url: /getting-started/sessions/tmux/ + icon: tmux + - title: cmux + url: /getting-started/sessions/cmux/ + icon: cmux + - title: Zellij + url: /getting-started/sessions/zellij/ + icon: zellij + - title: VS Code Extension + url: /getting-started/sessions/vscode/ + icon: vscode + - title: Supported Kanban Providers + url: /getting-started/kanban/ + children: + - title: Jira Cloud + url: /getting-started/kanban/jira/ + icon: jira + - title: Linear + url: /getting-started/kanban/linear/ + icon: linear - title: Supported Coding Agents url: /getting-started/agents/ children: @@ -19,21 +43,15 @@ docs: - title: Gemini CLI url: /getting-started/agents/gemini-cli/ icon: gemini - - title: Supported Kanban Providers - url: /getting-started/kanban/ - children: - - title: Jira Cloud - url: /getting-started/kanban/jira/ - icon: jira - - title: Linear - url: /getting-started/kanban/linear/ - icon: linear - title: Supported Git Repositories url: /getting-started/git/ children: - title: GitHub url: /getting-started/git/github/ icon: github + - title: GitLab + url: /getting-started/git/gitlab/ + icon: gitlab - title: Supported Notification Integrations url: /getting-started/notifications/ children: @@ -43,15 +61,6 @@ docs: - title: Operating System url: /getting-started/notifications/os/ icon: notification - - title: Supported Session Management - url: /getting-started/sessions/ - children: - - title: tmux - url: /getting-started/sessions/tmux/ - icon: tmux - - title: VS Code Extension - url: /getting-started/sessions/vscode/ - icon: vscode - title: Core children: - title: Kanban diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index d60fece..80087e0 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -49,7 +49,7 @@ {% for grandchild in item.children %}
  • {% if grandchild.icon %}{% endif %}{{ grandchild.title }} + {% endif %}>{% if grandchild.icon %}{% endif %}{{ grandchild.title }}
  • {% endfor %} diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index a6138c5..83b0d41 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -323,22 +323,9 @@ summary.nav-item-row::-webkit-details-marker { object-fit: contain; } -/* Light/dark icon swapping - show light by default */ -.nav-icon .icon-dark { - display: none; -} - -.nav-icon .icon-light { - display: inline-block; -} - -/* In dark mode, show dark icons and hide light icons */ -[data-theme="dark"] .nav-icon .icon-dark { - display: inline-block; -} - -[data-theme="dark"] .nav-icon .icon-light { - display: none; +/* Dark mode: invert icon colors */ +[data-theme="dark"] .nav-icon img { + filter: invert(1) brightness(1.1) sepia(0.3); } /* Downloads nav link - highlighted */ diff --git a/docs/assets/icons/claude.svg b/docs/assets/icons/claude.svg new file mode 100644 index 0000000..c33100c --- /dev/null +++ b/docs/assets/icons/claude.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/cmux.svg b/docs/assets/icons/cmux.svg new file mode 100644 index 0000000..32f2b8f --- /dev/null +++ b/docs/assets/icons/cmux.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/codex-dark.svg b/docs/assets/icons/codex-dark.svg deleted file mode 100644 index 528cbe9..0000000 --- a/docs/assets/icons/codex-dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - O - diff --git a/docs/assets/icons/codex-light.svg b/docs/assets/icons/codex-light.svg deleted file mode 100644 index 528cbe9..0000000 --- a/docs/assets/icons/codex-light.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - O - diff --git a/docs/assets/icons/codex.svg b/docs/assets/icons/codex.svg new file mode 100644 index 0000000..7997b3d --- /dev/null +++ b/docs/assets/icons/codex.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/gemini-dark.svg b/docs/assets/icons/gemini-dark.svg deleted file mode 100644 index 374d23c..0000000 --- a/docs/assets/icons/gemini-dark.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - G - diff --git a/docs/assets/icons/gemini-light.svg b/docs/assets/icons/gemini-light.svg deleted file mode 100644 index 8fdacef..0000000 --- a/docs/assets/icons/gemini-light.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - G - diff --git a/docs/assets/icons/gemini.svg b/docs/assets/icons/gemini.svg new file mode 100644 index 0000000..a801d93 --- /dev/null +++ b/docs/assets/icons/gemini.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/github-dark.svg b/docs/assets/icons/github-dark.svg deleted file mode 100644 index 2579551..0000000 --- a/docs/assets/icons/github-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/github-light.svg b/docs/assets/icons/github-light.svg deleted file mode 100644 index 9260040..0000000 --- a/docs/assets/icons/github-light.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/github.svg b/docs/assets/icons/github.svg new file mode 100644 index 0000000..8d44456 --- /dev/null +++ b/docs/assets/icons/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/gitlab.svg b/docs/assets/icons/gitlab.svg new file mode 100644 index 0000000..d99593f --- /dev/null +++ b/docs/assets/icons/gitlab.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/jira-dark.svg b/docs/assets/icons/jira-dark.svg deleted file mode 100644 index ad4b293..0000000 --- a/docs/assets/icons/jira-dark.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - J - diff --git a/docs/assets/icons/jira-light.svg b/docs/assets/icons/jira-light.svg deleted file mode 100644 index 87330ef..0000000 --- a/docs/assets/icons/jira-light.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - J - diff --git a/docs/assets/icons/jira.svg b/docs/assets/icons/jira.svg new file mode 100644 index 0000000..bb652f5 --- /dev/null +++ b/docs/assets/icons/jira.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/linear-dark.svg b/docs/assets/icons/linear-dark.svg deleted file mode 100644 index 4d8687f..0000000 --- a/docs/assets/icons/linear-dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - L - diff --git a/docs/assets/icons/linear-light.svg b/docs/assets/icons/linear-light.svg deleted file mode 100644 index 4d8687f..0000000 --- a/docs/assets/icons/linear-light.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - L - diff --git a/docs/assets/icons/linear.svg b/docs/assets/icons/linear.svg new file mode 100644 index 0000000..5c114d7 --- /dev/null +++ b/docs/assets/icons/linear.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/notification-dark.svg b/docs/assets/icons/notification-dark.svg deleted file mode 100644 index d8a32a7..0000000 --- a/docs/assets/icons/notification-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/notification-light.svg b/docs/assets/icons/notification-light.svg deleted file mode 100644 index a65140f..0000000 --- a/docs/assets/icons/notification-light.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/notification.svg b/docs/assets/icons/notification.svg new file mode 100644 index 0000000..72f41c1 --- /dev/null +++ b/docs/assets/icons/notification.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/tmux-dark.svg b/docs/assets/icons/tmux-dark.svg deleted file mode 100644 index 84d7e87..0000000 --- a/docs/assets/icons/tmux-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/tmux-light.svg b/docs/assets/icons/tmux-light.svg deleted file mode 100644 index f6acf94..0000000 --- a/docs/assets/icons/tmux-light.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/tmux.svg b/docs/assets/icons/tmux.svg new file mode 100644 index 0000000..f22913b --- /dev/null +++ b/docs/assets/icons/tmux.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/vscode-dark.svg b/docs/assets/icons/vscode-dark.svg deleted file mode 100644 index 22aa26b..0000000 --- a/docs/assets/icons/vscode-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/vscode-light.svg b/docs/assets/icons/vscode-light.svg deleted file mode 100644 index 4180c18..0000000 --- a/docs/assets/icons/vscode-light.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/vscode.svg b/docs/assets/icons/vscode.svg new file mode 100644 index 0000000..cfcccfc --- /dev/null +++ b/docs/assets/icons/vscode.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/webhook-dark.svg b/docs/assets/icons/webhook-dark.svg deleted file mode 100644 index d7a2386..0000000 --- a/docs/assets/icons/webhook-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/webhook-light.svg b/docs/assets/icons/webhook-light.svg deleted file mode 100644 index d72903a..0000000 --- a/docs/assets/icons/webhook-light.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/assets/icons/webhook.svg b/docs/assets/icons/webhook.svg new file mode 100644 index 0000000..be5ace0 --- /dev/null +++ b/docs/assets/icons/webhook.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/icons/zellij.svg b/docs/assets/icons/zellij.svg new file mode 100644 index 0000000..0d4abed --- /dev/null +++ b/docs/assets/icons/zellij.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/getting-started/git/gitlab.md b/docs/getting-started/git/gitlab.md index 175a546..b0c31df 100644 --- a/docs/getting-started/git/gitlab.md +++ b/docs/getting-started/git/gitlab.md @@ -2,7 +2,7 @@ title: "GitLab" description: "Configure GitLab integration with Operator (Coming Soon)." layout: doc -published: false +published: true --- # GitLab diff --git a/docs/getting-started/sessions/zellij.md b/docs/getting-started/sessions/zellij.md index 9a15fb9..846ec62 100644 --- a/docs/getting-started/sessions/zellij.md +++ b/docs/getting-started/sessions/zellij.md @@ -48,7 +48,7 @@ require_in_zellij = true When Operator launches a ticket: 1. Checks that Zellij is available and Operator is running inside Zellij -2. Creates a new tab named `op-{TICKET_ID}` with the project directory as the working directory +2. Creates a new tab named `op:{PROJECT}:{TICKET_ID}` with the project directory as the working directory 3. Sends the LLM agent command to the tab ### Focusing Agents @@ -115,5 +115,5 @@ require_in_zellij = false ### Agent tab not appearing 1. Check that you're running Operator inside a Zellij session -2. Look at Zellij's tab bar for tabs prefixed with `op-` +2. Look at Zellij's tab bar for tabs prefixed with `op:` 3. Check Operator logs for zellij-related errors diff --git a/vscode-extension/images/icons/atlassian.svg b/icons/atlassian.svg similarity index 84% rename from vscode-extension/images/icons/atlassian.svg rename to icons/atlassian.svg index 8752138..b66d924 100644 --- a/vscode-extension/images/icons/atlassian.svg +++ b/icons/atlassian.svg @@ -1 +1 @@ -Atlassian \ No newline at end of file +Atlassian diff --git a/vscode-extension/images/icons/claude.svg b/icons/claude.svg similarity index 99% rename from vscode-extension/images/icons/claude.svg rename to icons/claude.svg index 1beee86..0f8385a 100644 --- a/vscode-extension/images/icons/claude.svg +++ b/icons/claude.svg @@ -1 +1 @@ -Claude \ No newline at end of file +Claude diff --git a/icons/cmux.svg b/icons/cmux.svg new file mode 100644 index 0000000..9e00549 --- /dev/null +++ b/icons/cmux.svg @@ -0,0 +1 @@ +cmux diff --git a/vscode-extension/images/icons/codex.svg b/icons/codex.svg similarity index 97% rename from vscode-extension/images/icons/codex.svg rename to icons/codex.svg index 31cf782..99844e1 100644 --- a/vscode-extension/images/icons/codex.svg +++ b/icons/codex.svg @@ -1 +1 @@ -Codex \ No newline at end of file +Codex diff --git a/vscode-extension/images/icons/gemini.svg b/icons/gemini.svg similarity index 95% rename from vscode-extension/images/icons/gemini.svg rename to icons/gemini.svg index 765adb0..60197dc 100644 --- a/vscode-extension/images/icons/gemini.svg +++ b/icons/gemini.svg @@ -1 +1 @@ -Google Gemini \ No newline at end of file +Google Gemini diff --git a/icons/github.svg b/icons/github.svg new file mode 100644 index 0000000..2334976 --- /dev/null +++ b/icons/github.svg @@ -0,0 +1 @@ +GitHub diff --git a/icons/gitlab.svg b/icons/gitlab.svg new file mode 100644 index 0000000..ab1286a --- /dev/null +++ b/icons/gitlab.svg @@ -0,0 +1 @@ +GitLab diff --git a/vscode-extension/images/icons/linear.svg b/icons/linear.svg similarity index 98% rename from vscode-extension/images/icons/linear.svg rename to icons/linear.svg index f3770d6..ada2e9d 100644 --- a/vscode-extension/images/icons/linear.svg +++ b/icons/linear.svg @@ -1 +1 @@ -Linear \ No newline at end of file +Linear diff --git a/icons/notification.svg b/icons/notification.svg new file mode 100644 index 0000000..3b0ebdf --- /dev/null +++ b/icons/notification.svg @@ -0,0 +1 @@ +Notification diff --git a/icons/tmux.svg b/icons/tmux.svg new file mode 100644 index 0000000..921c63b --- /dev/null +++ b/icons/tmux.svg @@ -0,0 +1 @@ +tmux diff --git a/icons/vscode.svg b/icons/vscode.svg new file mode 100644 index 0000000..92f3ae8 --- /dev/null +++ b/icons/vscode.svg @@ -0,0 +1 @@ +Visual Studio Code diff --git a/icons/webhook.svg b/icons/webhook.svg new file mode 100644 index 0000000..9321283 --- /dev/null +++ b/icons/webhook.svg @@ -0,0 +1 @@ +Webhooks diff --git a/icons/zellij.svg b/icons/zellij.svg new file mode 100644 index 0000000..160b3c0 --- /dev/null +++ b/icons/zellij.svg @@ -0,0 +1 @@ +Zellij diff --git a/src/agents/cmux.rs b/src/agents/cmux.rs index 743ea06..e70dab8 100644 --- a/src/agents/cmux.rs +++ b/src/agents/cmux.rs @@ -19,7 +19,9 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use thiserror::Error; -use crate::agents::terminal_wrapper::{SessionError, SessionInfo, SessionWrapper, WrapperType}; +use crate::agents::terminal_wrapper::{ + SessionError, SessionInfo, SessionTopology, SessionWrapper, WrapperType, +}; use crate::config::{CmuxPlacementPolicy, SessionsCmuxConfig}; /// Errors specific to cmux operations @@ -107,6 +109,9 @@ pub trait CmuxClient: Send + Sync { /// Rename a window fn rename_window(&self, window_ref: &str, name: &str) -> Result<(), CmuxError>; + + /// Set the subtitle/metadata for a workspace (shown in cmux sidebar) + fn set_workspace_subtitle(&self, workspace_ref: &str, subtitle: &str) -> Result<(), CmuxError>; } // ============================================================================ @@ -268,6 +273,28 @@ impl CmuxClient for SystemCmuxClient { self.run_cmux_success(&["rename-window", "--window", window_ref, "--name", name])?; Ok(()) } + + fn set_workspace_subtitle(&self, workspace_ref: &str, subtitle: &str) -> Result<(), CmuxError> { + let output = std::process::Command::new(&self.binary_path) + .args([ + "set-workspace-subtitle", + "--workspace", + workspace_ref, + "--subtitle", + subtitle, + ]) + .output() + .map_err(|e| CmuxError::CommandFailed(format!("failed to run cmux: {e}")))?; + + if !output.status.success() { + // cmux may not support this command yet — log and continue + tracing::debug!( + "cmux set-workspace-subtitle not supported or failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) + } } // ============================================================================ @@ -304,6 +331,7 @@ struct MockState { next_window_id: u32, active_window_id: String, sent_texts: Vec<(String, String)>, // (workspace_ref, text) + subtitles: HashMap, } /// Mock implementation for testing @@ -328,6 +356,7 @@ impl MockCmuxClient { next_window_id: 2, active_window_id: "win-1".to_string(), sent_texts: vec![], + subtitles: HashMap::new(), }), } } @@ -395,6 +424,12 @@ impl MockCmuxClient { .map(|ws| ws.id.clone()) }) } + + /// Get the subtitle set for a workspace (for test assertions) + pub fn get_subtitle(&self, workspace_ref: &str) -> Option { + let state = self.state.lock().unwrap(); + state.subtitles.get(workspace_ref).cloned() + } } impl Default for MockCmuxClient { @@ -612,6 +647,14 @@ impl CmuxClient for MockCmuxClient { w.name = Some(name.to_string()); Ok(()) } + + fn set_workspace_subtitle(&self, workspace_ref: &str, subtitle: &str) -> Result<(), CmuxError> { + let mut state = self.state.lock().unwrap(); + state + .subtitles + .insert(workspace_ref.to_string(), subtitle.to_string()); + Ok(()) + } } // ============================================================================ @@ -838,6 +881,33 @@ impl SessionWrapper for CmuxWrapper { fn supports_content_capture(&self) -> bool { true } + + fn session_topology(&self, session: &str) -> Result { + let mut refs = Vec::new(); + + if let Some(window_ref) = self.window_ref(session) { + refs.push(("window".to_string(), window_ref)); + } + if let Some(workspace_ref) = self.workspace_ref(session) { + refs.push(("workspace".to_string(), workspace_ref)); + } + + if refs.is_empty() { + return Err(SessionError::SessionNotFound(session.to_string())); + } + + let placement_hint = Some(match self.placement { + CmuxPlacementPolicy::Auto => "auto".to_string(), + CmuxPlacementPolicy::Workspace => "workspace (same window)".to_string(), + CmuxPlacementPolicy::Window => "window (new window)".to_string(), + }); + + Ok(SessionTopology { + wrapper_type: WrapperType::Cmux, + refs, + placement_hint, + }) + } } #[cfg(test)] @@ -945,6 +1015,33 @@ mod tests { assert!(client.rename_workspace("ws-999", "x").is_err()); } + #[tokio::test] + async fn test_cmux_set_workspace_subtitle() { + let client = MockCmuxClient::new(); + let client_arc: Arc = Arc::new(client); + let config = SessionsCmuxConfig { + binary_path: "/mock/cmux".to_string(), + require_in_cmux: true, + placement: CmuxPlacementPolicy::Auto, + }; + let wrapper = CmuxWrapper::new(client_arc.clone(), &config); + + wrapper + .create_session("op-TASK-030", "/tmp/project") + .await + .unwrap(); + + let ws_ref = wrapper.workspace_ref("op-TASK-030").unwrap(); + client_arc + .set_workspace_subtitle(&ws_ref, "implement | ▶") + .unwrap(); + + assert_eq!( + client_arc.get_subtitle(&ws_ref).as_deref(), + Some("implement | ▶") + ); + } + // ======================================================================== // CmuxWrapper SessionWrapper tests // ======================================================================== @@ -1193,4 +1290,28 @@ mod tests { // Verify wrapper type assert_eq!(tasks[0].wrapper_type, WrapperType::Cmux); } + + #[tokio::test] + async fn test_cmux_session_topology() { + let wrapper = make_wrapper(MockCmuxClient::new(), CmuxPlacementPolicy::Auto); + + wrapper + .create_session("op-TASK-020", "/tmp/project") + .await + .unwrap(); + + let topology = wrapper.session_topology("op-TASK-020").unwrap(); + assert_eq!(topology.wrapper_type, WrapperType::Cmux); + assert!(!topology.refs.is_empty()); + assert!(topology.refs.iter().any(|(label, _)| label == "workspace")); + assert!(topology.refs.iter().any(|(label, _)| label == "window")); + assert_eq!(topology.placement_hint.as_deref(), Some("auto")); + } + + #[tokio::test] + async fn test_cmux_session_topology_not_found() { + let wrapper = make_wrapper(MockCmuxClient::new(), CmuxPlacementPolicy::Auto); + let err = wrapper.session_topology("nonexistent").unwrap_err(); + assert!(matches!(err, SessionError::SessionNotFound(_))); + } } diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index f8eb295..053dfbd 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -72,6 +72,12 @@ pub struct PreparedLaunch { pub worktree_created: bool, /// Branch name (if worktree was created) pub branch: Option, + /// Which session wrapper was used: "tmux", "vscode", "cmux", or "zellij" + pub session_wrapper: Option, + /// Session window reference ID (e.g. cmux window, tmux session) + pub session_window_ref: Option, + /// Session context reference (e.g. cmux workspace, zellij session) + pub session_context_ref: Option, } /// Minimum required tmux version @@ -519,6 +525,11 @@ impl Launcher { llm_cmd = apply_yolo_flags(&self.config, &llm_cmd, &tool_name); } + // Apply extra flags from delegator launch_config + if !options.extra_flags.is_empty() { + llm_cmd = format!("{} {}", llm_cmd, options.extra_flags.join(" ")); + } + // Wrap in docker command if docker mode is enabled if options.docker_mode { llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str)?; @@ -582,6 +593,9 @@ impl Launcher { session_id: session_uuid, worktree_created, branch, + session_wrapper: None, + session_window_ref: None, + session_context_ref: None, }) } @@ -743,6 +757,15 @@ impl Launcher { llm_cmd = apply_yolo_flags(&self.config, &llm_cmd, &tool_name); } + // Apply extra flags from delegator launch_config + if !options.launch_options.extra_flags.is_empty() { + llm_cmd = format!( + "{} {}", + llm_cmd, + options.launch_options.extra_flags.join(" ") + ); + } + // Wrap in docker command if docker mode is enabled if options.launch_options.docker_mode { llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str)?; @@ -809,6 +832,9 @@ impl Launcher { session_id: session_uuid, worktree_created, branch, + session_wrapper: None, + session_window_ref: None, + session_context_ref: None, }) } diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index 7abe014..6fd1a51 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -7,6 +7,10 @@ use crate::config::LlmProvider; pub struct LaunchOptions { /// LLM provider to use (if None, use default) pub provider: Option, + /// Delegator name used for this launch (for state tracking and step-level switching) + pub delegator_name: Option, + /// Additional CLI flags from delegator `launch_config` + pub extra_flags: Vec, /// Run in docker container pub docker_mode: bool, /// Run in YOLO (auto-accept) mode diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index a63091a..f1e7925 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -22,8 +22,6 @@ use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, write_prompt_file, }; -use super::SESSION_PREFIX; - /// Result of launching in zellij — includes tab name for state tracking #[derive(Debug, Clone)] pub struct ZellijLaunchResult { @@ -49,8 +47,12 @@ pub fn launch_in_zellij_with_options( .check_in_zellij() .map_err(|e| anyhow::anyhow!("Not running inside zellij: {e}"))?; - // Create session name from ticket ID - let session_name = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); + // Create session name from ticket ID with project for scannable Zellij tab bar + let session_name = format!( + "op:{}:{}", + sanitize_session_name(&ticket.project), + sanitize_session_name(&ticket.id) + ); // Tab name = session name (1:1 mapping) let tab_name = session_name.clone(); @@ -174,7 +176,11 @@ pub fn launch_in_zellij_with_relaunch_options( .check_in_zellij() .map_err(|e| anyhow::anyhow!("Not running inside zellij: {e}"))?; - let session_name = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); + let session_name = format!( + "op:{}:{}", + sanitize_session_name(&ticket.project), + sanitize_session_name(&ticket.id) + ); // Tab name = session name (1:1 mapping) let tab_name = session_name.clone(); diff --git a/src/agents/terminal_wrapper.rs b/src/agents/terminal_wrapper.rs index 90e6528..5a81767 100644 --- a/src/agents/terminal_wrapper.rs +++ b/src/agents/terminal_wrapper.rs @@ -70,6 +70,17 @@ pub struct SessionInfo { pub wrapper_type: WrapperType, } +/// Topology information about a session's placement in its wrapper hierarchy +#[derive(Debug, Clone)] +pub struct SessionTopology { + /// Type of wrapper hosting this session + pub wrapper_type: WrapperType, + /// Hierarchical refs as label-value pairs (e.g. [("window", "abc123"), ("workspace", "def456")]) + pub refs: Vec<(String, String)>, + /// Human-readable placement hint (e.g. "auto (same window)" or "new window") + pub placement_hint: Option, +} + /// Core terminal session operations /// /// This trait abstracts the terminal-level operations needed to manage @@ -116,6 +127,14 @@ pub trait SessionWrapper: Send + Sync { fn supports_content_capture(&self) -> bool { false } + + /// Get topology info for a session (hierarchy refs, placement info) + /// Default returns `NotSupported` — only wrappers with rich hierarchy implement this. + fn session_topology(&self, _session: &str) -> Result { + Err(SessionError::NotSupported( + "topology not available for this wrapper".into(), + )) + } } /// Configuration for activity detection diff --git a/src/agents/vscode_types.rs b/src/agents/vscode_types.rs index 9aed24b..d6a31af 100644 --- a/src/agents/vscode_types.rs +++ b/src/agents/vscode_types.rs @@ -193,7 +193,10 @@ pub enum VsCodeModelOption { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] pub struct VsCodeLaunchOptions { - /// Model to use (sonnet, opus, haiku) + /// Named delegator to use (takes precedence over model) + #[serde(default)] + pub delegator: Option, + /// Model to use (sonnet, opus, haiku) — fallback when no delegator pub model: VsCodeModelOption, /// YOLO mode - auto-accept all prompts pub yolo_mode: bool, diff --git a/src/app/agents.rs b/src/app/agents.rs index 87c9a91..5a2c768 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -6,8 +6,10 @@ use crate::agents::cmux::{CmuxClient, SystemCmuxClient}; use crate::agents::tmux::{SystemTmuxClient, TmuxClient}; use crate::agents::zellij::{SystemZellijClient, ZellijClient}; use crate::agents::{LaunchOptions, Launcher}; +use crate::config::SessionWrapperType; use crate::state::State; use crate::ui::dashboard::FocusedPanel; +use crate::ui::dialogs::SessionPlacementPreview; use crate::ui::with_suspended_tui; use super::{App, AppTerminal}; @@ -142,14 +144,17 @@ impl App { // Check if we can launch let state = State::load(&self.config)?; let running_count = state.running_agents().len(); + let max = self.config.effective_max_agents(); - if running_count >= self.config.effective_max_agents() { - // Could show an error dialog here + if running_count >= max { + self.dashboard.set_status(&format!( + "Cannot launch: {running_count}/{max} agents active" + )); return Ok(()); } if self.dashboard.paused { - // Could show an error dialog here + self.dashboard.set_status("Cannot launch: queue is paused"); return Ok(()); } @@ -157,7 +162,10 @@ impl App { if let Some(ticket) = self.dashboard.selected_ticket().cloned() { // Check if project is already busy if state.is_project_busy(&ticket.project) { - // Could show an error dialog here + self.dashboard.set_status(&format!( + "Cannot launch: {} has an active agent", + ticket.project + )); return Ok(()); } @@ -169,6 +177,9 @@ impl App { self.config.launch.yolo.enabled, ); + // Build session placement preview + self.confirm_dialog.session_preview = self.build_session_placement_preview(); + // Show confirmation self.confirm_dialog.show(ticket); } @@ -190,6 +201,8 @@ impl App { let options = LaunchOptions { provider: self.confirm_dialog.selected_provider().cloned(), + delegator_name: None, + extra_flags: Vec::new(), docker_mode: self.confirm_dialog.docker_selected, yolo_mode: self.confirm_dialog.yolo_selected, project_override, @@ -257,12 +270,15 @@ impl App { error = %e, "Failed to focus cmux workspace" ); + self.dashboard.set_status(&format!("Focus failed: {e}")); } } else { tracing::warn!( session = %session_name, "cmux agent has no workspace ref" ); + self.dashboard + .set_status("Cannot focus: no cmux workspace ref"); } } else if let Some("zellij") = session_wrapper.as_deref() { // For zellij agents, focus the tab (no TUI suspension needed) @@ -329,6 +345,94 @@ impl App { Ok(()) } + /// Build a session placement preview for the launch confirmation dialog. + fn build_session_placement_preview(&self) -> Option { + match self.config.sessions.wrapper { + SessionWrapperType::Cmux => { + let cmux = SystemCmuxClient::from_config(&self.config.sessions.cmux); + if cmux.check_available().is_err() || cmux.check_in_cmux().is_err() { + return Some(SessionPlacementPreview { + wrapper_type: "cmux".to_string(), + placement_description: "unavailable".to_string(), + target_info: vec![], + }); + } + + let window_count = cmux.window_count().unwrap_or(0); + let policy = &self.config.sessions.cmux.placement; + let placement_desc = match policy { + crate::config::CmuxPlacementPolicy::Auto => { + if window_count <= 1 { + "auto -> same window".to_string() + } else { + "auto -> new window".to_string() + } + } + crate::config::CmuxPlacementPolicy::Workspace => { + "workspace (same window)".to_string() + } + crate::config::CmuxPlacementPolicy::Window => "new window".to_string(), + }; + + let mut target_info = vec![]; + if let Ok(win_id) = cmux.active_window_id() { + target_info.push(("window".to_string(), win_id)); + } + target_info.push(("windows".to_string(), window_count.to_string())); + + Some(SessionPlacementPreview { + wrapper_type: "cmux".to_string(), + placement_description: placement_desc, + target_info, + }) + } + SessionWrapperType::Tmux => Some(SessionPlacementPreview { + wrapper_type: "tmux".to_string(), + placement_description: "new session".to_string(), + target_info: vec![], + }), + SessionWrapperType::Zellij => Some(SessionPlacementPreview { + wrapper_type: "zellij".to_string(), + placement_description: "new tab".to_string(), + target_info: vec![], + }), + SessionWrapperType::Vscode => Some(SessionPlacementPreview { + wrapper_type: "vscode".to_string(), + placement_description: "terminal".to_string(), + target_info: vec![], + }), + } + } + + /// Focus the cmux window containing the selected agent's workspace. + /// This is a cmux power-user action — other wrappers show a status message. + pub(super) fn focus_agent_window(&mut self) -> Result<()> { + let agent = self.dashboard.selected_agent().cloned(); + let Some(agent) = agent else { + return Ok(()); + }; + + if agent.session_wrapper.as_deref() != Some("cmux") { + self.dashboard + .set_status("F: cmux window focus — not a cmux agent"); + return Ok(()); + } + + let Some(ref window_ref) = agent.session_window_ref else { + self.dashboard + .set_status("Cannot focus: no cmux window ref"); + return Ok(()); + }; + + let cmux = SystemCmuxClient::from_config(&self.config.sessions.cmux); + if let Err(e) = cmux.focus_window(window_ref) { + self.dashboard + .set_status(&format!("Failed to focus window: {e}")); + } + + Ok(()) + } + /// Show session preview for the selected agent pub(super) fn show_session_preview(&mut self) -> Result<()> { // Only works when agents or awaiting panel is focused diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index 87bcfca..90f5ebf 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -331,9 +331,15 @@ impl App { KeyCode::Down | KeyCode::Char('j') => { self.dashboard.select_next(); } - KeyCode::Char('L' | 'l') => { + KeyCode::Char('L') => { self.try_launch()?; } + KeyCode::Char('h') | KeyCode::Left => { + self.dashboard.focus_prev(); + } + KeyCode::Char('l') | KeyCode::Right => { + self.dashboard.focus_next(); + } KeyCode::Enter => { // Enter key behavior depends on focused panel match self.dashboard.focused { @@ -440,6 +446,10 @@ impl App { // Open kanban providers view self.show_kanban_view(); } + KeyCode::Char('F') => { + // Focus agent's cmux window (cmux power-user action) + self.focus_agent_window()?; + } _ => {} } diff --git a/src/app/mod.rs b/src/app/mod.rs index b8c8fdb..5f59e87 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -256,12 +256,13 @@ impl App { } let kanban_sync_service = KanbanSyncService::new(&config); + let help_dialog = HelpDialog::new(config.sessions.wrapper); Ok(Self { config, dashboard, confirm_dialog: ConfirmDialog::new(), - help_dialog: HelpDialog::new(), + help_dialog, create_dialog, projects_dialog, setup_screen, @@ -438,6 +439,9 @@ impl App { self.update_notification_shown_at = None; } } + + // Auto-dismiss status messages after 5 seconds + self.dashboard.clear_expired_status(); } // Terminal cleanup is handled by _terminal_guard drop diff --git a/src/config.rs b/src/config.rs index d2ef234..11162fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -383,17 +383,24 @@ pub enum SessionWrapperType { Zellij, } -impl std::fmt::Display for SessionWrapperType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl SessionWrapperType { + /// Short display name for the wrapper (used in header bar, logs) + pub fn display_name(&self) -> &'static str { match self { - SessionWrapperType::Tmux => write!(f, "tmux"), - SessionWrapperType::Vscode => write!(f, "vscode"), - SessionWrapperType::Cmux => write!(f, "cmux"), - SessionWrapperType::Zellij => write!(f, "zellij"), + SessionWrapperType::Tmux => "tmux", + SessionWrapperType::Vscode => "vscode", + SessionWrapperType::Cmux => "cmux", + SessionWrapperType::Zellij => "zellij", } } } +impl std::fmt::Display for SessionWrapperType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.display_name()) + } +} + /// Session wrapper configuration /// /// Controls how operator creates and manages terminal sessions for agents. @@ -783,7 +790,7 @@ pub struct LlmToolsConfig { } /// A detected CLI tool (e.g., claude binary) -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] #[ts(export)] pub struct DetectedTool { /// Tool name (e.g., "claude") @@ -799,6 +806,7 @@ pub struct DetectedTool { #[serde(default)] pub version_ok: bool, /// Available model aliases (e.g., ["opus", "sonnet", "haiku"]) + #[serde(default)] pub model_aliases: Vec, /// Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders #[serde(default)] @@ -812,7 +820,7 @@ pub struct DetectedTool { } /// Tool capabilities -#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS, utoipa::ToSchema)] #[ts(export)] pub struct ToolCapabilities { /// Whether the tool supports session continuity via UUID @@ -1084,6 +1092,7 @@ pub struct JiraConfig { #[serde(default = "default_jira_api_key_env")] pub api_key_env: String, /// Atlassian account email for authentication + #[serde(default)] pub email: String, /// Per-project sync configuration #[serde(default)] @@ -1328,9 +1337,30 @@ impl Config { ); let config = builder.build().context("Failed to load configuration")?; - config - .try_deserialize() - .context("Failed to deserialize configuration") + config.try_deserialize().map_err(|e| { + let mut sources = vec![]; + let operator_config = Self::operator_config_path(); + if operator_config.exists() { + sources.push(format!(" - {}", operator_config.display())); + } + if let Some(config_dir) = dirs::config_dir() { + let user_config = config_dir.join("operator").join("config.toml"); + if user_config.exists() { + sources.push(format!(" - {}", user_config.display())); + } + } + if let Some(path) = config_path { + sources.push(format!(" - {path}")); + } + let sources_str = if sources.is_empty() { + String::from(" (no config files found)") + } else { + sources.join("\n") + }; + anyhow::anyhow!( + "Failed to deserialize configuration: {e}\n\nConfig files loaded:\n{sources_str}\n\nCheck these files for missing or invalid fields." + ) + }) } /// Save config to .tickets/operator/config.toml diff --git a/src/docs_gen/shortcuts.rs b/src/docs_gen/shortcuts.rs index 1933b17..61ff9e4 100644 --- a/src/docs_gen/shortcuts.rs +++ b/src/docs_gen/shortcuts.rs @@ -121,7 +121,7 @@ mod tests { assert!(result.contains("`Tab`")); assert!(result.contains("`j/↓`")); assert!(result.contains("`Enter`")); - assert!(result.contains("`L/l`")); + assert!(result.contains("`L`")); } #[test] diff --git a/src/rest/dto.rs b/src/rest/dto.rs index 8171d4f..e914cb0 100644 --- a/src/rest/dto.rs +++ b/src/rest/dto.rs @@ -597,6 +597,18 @@ pub struct ActiveAgentResponse { /// Current workflow step #[serde(skip_serializing_if = "Option::is_none")] pub current_step: Option, + /// Which session wrapper is in use: "tmux", "vscode", "cmux", or "zellij" + #[serde(skip_serializing_if = "Option::is_none")] + pub session_wrapper: Option, + /// Session window reference ID (e.g. cmux window, tmux session) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_window_ref: Option, + /// Session context reference (e.g. cmux workspace, zellij session) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_context_ref: Option, + /// Session pane reference (e.g. cmux surface, zellij pane) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_pane_ref: Option, } /// Response for active agents list @@ -617,10 +629,13 @@ pub struct ActiveAgentsResponse { #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct LaunchTicketRequest { - /// LLM provider to use (e.g., "claude") + /// Named delegator to use (takes precedence over provider/model) + #[serde(default)] + pub delegator: Option, + /// LLM provider to use (e.g., "claude") — legacy fallback when no delegator #[serde(default)] pub provider: Option, - /// Model to use (e.g., "sonnet", "opus") + /// Model to use (e.g., "sonnet", "opus") — legacy fallback when no delegator #[serde(default)] pub model: Option, /// Run in YOLO mode (auto-accept all prompts) @@ -659,6 +674,9 @@ pub struct LaunchTicketResponse { /// Session window reference ID (e.g. cmux window, tmux session) #[serde(skip_serializing_if = "Option::is_none")] pub session_window_ref: Option, + /// Session context reference (e.g. cmux workspace, zellij session) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_context_ref: Option, /// Session UUID for the LLM tool pub session_id: String, /// Whether a worktree was created @@ -1015,6 +1033,20 @@ pub struct DelegatorsResponse { pub total: usize, } +// ============================================================================= +// LLM Tools DTOs +// ============================================================================= + +/// Response listing detected LLM tools +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LlmToolsResponse { + /// Detected CLI tools with model aliases and capabilities + pub tools: Vec, + /// Total count + pub total: usize, +} + #[cfg(test)] mod tests { use super::*; @@ -1153,6 +1185,7 @@ mod tests { tmux_session_name: "op-FEAT-001".to_string(), session_wrapper: Some("cmux".to_string()), session_window_ref: Some("win-1".to_string()), + session_context_ref: Some("ws-1".to_string()), session_id: "uuid-1".to_string(), worktree_created: false, branch: None, @@ -1161,6 +1194,7 @@ mod tests { let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"session_wrapper\":\"cmux\"")); assert!(json.contains("\"session_window_ref\":\"win-1\"")); + assert!(json.contains("\"session_context_ref\":\"ws-1\"")); } #[test] @@ -1174,6 +1208,7 @@ mod tests { tmux_session_name: "op-FEAT-001".to_string(), session_wrapper: None, session_window_ref: None, + session_context_ref: None, session_id: "uuid-1".to_string(), worktree_created: false, branch: None, @@ -1182,6 +1217,7 @@ mod tests { let json = serde_json::to_string(&resp).unwrap(); assert!(!json.contains("session_wrapper")); assert!(!json.contains("session_window_ref")); + assert!(!json.contains("session_context_ref")); } #[test] diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 7400d14..c381118 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -118,6 +118,8 @@ pub fn build_router(state: ApiState) -> Router { ) // Skills endpoint .route("/api/v1/skills", get(routes::skills::list)) + // LLM tools endpoint + .route("/api/v1/llm-tools", get(routes::llm_tools::list)) // Delegator endpoints .route("/api/v1/delegators", get(routes::delegators::list)) .route("/api/v1/delegators", post(routes::delegators::create)) diff --git a/src/rest/routes/agents.rs b/src/rest/routes/agents.rs index 4799eec..b58eb69 100644 --- a/src/rest/routes/agents.rs +++ b/src/rest/routes/agents.rs @@ -50,6 +50,10 @@ pub async fn active(State(state): State) -> Result LaunchTicketResponse command: prepared.command, terminal_name: prepared.terminal_name.clone(), tmux_session_name: prepared.terminal_name, - session_wrapper: None, - session_window_ref: None, + session_wrapper: prepared.session_wrapper, + session_window_ref: prepared.session_window_ref, + session_context_ref: prepared.session_context_ref, session_id: prepared.session_id, worktree_created: prepared.worktree_created, branch: prepared.branch, @@ -98,7 +99,40 @@ pub async fn launch_ticket( Ok(Json(prepared_launch_to_response(prepared))) } +/// Convert a `Delegator` into an `LlmProvider` +fn delegator_to_provider(d: &Delegator) -> LlmProvider { + LlmProvider { + tool: d.llm_tool.clone(), + model: d.model.clone(), + ..Default::default() + } +} + +/// Resolve a default delegator when none is explicitly specified. +/// +/// Resolution chain: +/// 1. Single configured delegator → use it +/// 2. Delegator matching the user's preferred LLM tool → use it +/// 3. None → caller falls back to first detected tool + first model alias +fn resolve_default_delegator(config: &Config) -> Option<&Delegator> { + match config.delegators.len() { + 0 => None, + 1 => Some(&config.delegators[0]), + _ => { + // Prefer delegator matching the user's preferred LLM tool + let preferred_tool = config.llm_tools.detected.first().map(|t| &t.name); + if let Some(tool_name) = preferred_tool { + config.delegators.iter().find(|d| &d.llm_tool == tool_name) + } else { + Some(&config.delegators[0]) + } + } + } +} + /// Build `LaunchOptions` from the request +/// +/// Resolution chain: delegator name > provider/model > default delegator > detected tool defaults fn build_launch_options( state: &ApiState, request: &LaunchTicketRequest, @@ -108,9 +142,29 @@ fn build_launch_options( ..Default::default() }; - // Set provider/model if specified + // 1. Explicit delegator name takes precedence + if let Some(ref delegator_name) = request.delegator { + let delegator = state + .config + .delegators + .iter() + .find(|d| d.name == *delegator_name) + .ok_or_else(|| ApiError::BadRequest(format!("Unknown delegator '{delegator_name}'")))?; + + options.provider = Some(delegator_to_provider(delegator)); + options.delegator_name = Some(delegator.name.clone()); + + // Apply delegator launch_config + if let Some(ref lc) = delegator.launch_config { + options.yolo_mode = options.yolo_mode || lc.yolo; + options.extra_flags.clone_from(&lc.flags); + } + + return Ok(options); + } + + // 2. Legacy: explicit provider/model if let Some(ref provider_name) = request.provider { - // Find the provider in config by tool name let provider = state .config .llm_tools @@ -120,7 +174,6 @@ fn build_launch_options( .cloned(); if let Some(p) = provider { - // Use specified model or default to provider's model let model = request.model.clone().unwrap_or(p.model.clone()); options.provider = Some(LlmProvider { tool: p.tool, @@ -132,8 +185,11 @@ fn build_launch_options( "Unknown provider '{provider_name}'" ))); } - } else if let Some(ref model) = request.model { - // Model specified without provider - use default provider with custom model + + return Ok(options); + } + + if let Some(ref model) = request.model { if let Some(p) = state.config.llm_tools.providers.first().cloned() { options.provider = Some(LlmProvider { tool: p.tool, @@ -141,6 +197,35 @@ fn build_launch_options( ..Default::default() }); } + + return Ok(options); + } + + // 3. No explicit selection — resolve default delegator + if let Some(delegator) = resolve_default_delegator(&state.config) { + options.provider = Some(delegator_to_provider(delegator)); + options.delegator_name = Some(delegator.name.clone()); + + if let Some(ref lc) = delegator.launch_config { + options.yolo_mode = options.yolo_mode || lc.yolo; + options.extra_flags.clone_from(&lc.flags); + } + + return Ok(options); + } + + // 4. No delegators at all — fall back to first detected tool + first model alias + if let Some(tool) = state.config.llm_tools.detected.first() { + let model = tool + .model_aliases + .first() + .cloned() + .unwrap_or_else(|| "default".to_string()); + options.provider = Some(LlmProvider { + tool: tool.name.clone(), + model, + ..Default::default() + }); } Ok(options) @@ -302,6 +387,7 @@ mod tests { fn test_build_launch_options_default() { let state = make_state(); let request = LaunchTicketRequest { + delegator: None, provider: None, model: None, yolo_mode: false, @@ -322,6 +408,7 @@ mod tests { fn test_build_launch_options_yolo() { let state = make_state(); let request = LaunchTicketRequest { + delegator: None, provider: None, model: None, yolo_mode: true, @@ -341,6 +428,7 @@ mod tests { fn test_build_launch_options_unknown_provider() { let state = make_state(); let request = LaunchTicketRequest { + delegator: None, provider: Some("unknown-provider".to_string()), model: None, yolo_mode: false, @@ -357,6 +445,7 @@ mod tests { fn test_build_relaunch_options() { let state = make_state(); let request = LaunchTicketRequest { + delegator: None, provider: None, model: None, yolo_mode: false, diff --git a/src/rest/routes/llm_tools.rs b/src/rest/routes/llm_tools.rs new file mode 100644 index 0000000..48c5925 --- /dev/null +++ b/src/rest/routes/llm_tools.rs @@ -0,0 +1,68 @@ +//! LLM tools endpoint. +//! +//! Returns detected CLI tools with their model aliases, capabilities, +//! and version information from the operator configuration. + +use axum::extract::State; +use axum::Json; + +use crate::rest::dto::LlmToolsResponse; +use crate::rest::state::ApiState; + +/// List detected LLM tools with model aliases +#[utoipa::path( + get, + path = "/api/v1/llm-tools", + tag = "LLM Tools", + responses( + (status = 200, description = "List of detected LLM tools", body = LlmToolsResponse) + ) +)] +pub async fn list(State(state): State) -> Json { + let tools = state.config.llm_tools.detected.clone(); + let total = tools.len(); + Json(LlmToolsResponse { tools, total }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, DetectedTool}; + use std::path::PathBuf; + + #[tokio::test] + async fn test_list_empty() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let resp = list(State(state)).await; + assert_eq!(resp.total, 0); + assert!(resp.tools.is_empty()); + } + + #[tokio::test] + async fn test_list_with_tools() { + let mut config = Config::default(); + config.llm_tools.detected.push(DetectedTool { + name: "claude".to_string(), + path: "/usr/local/bin/claude".to_string(), + version: "2.5.0".to_string(), + min_version: Some("2.1.0".to_string()), + version_ok: true, + model_aliases: vec![ + "opus".to_string(), + "sonnet".to_string(), + "haiku".to_string(), + ], + command_template: String::new(), + capabilities: Default::default(), + yolo_flags: vec![], + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let resp = list(State(state)).await; + assert_eq!(resp.total, 1); + assert_eq!(resp.tools[0].name, "claude"); + assert_eq!(resp.tools[0].model_aliases.len(), 3); + } +} diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 8a4779d..0441f0f 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -7,6 +7,7 @@ pub mod health; pub mod issuetypes; pub mod kanban; pub mod launch; +pub mod llm_tools; pub mod projects; pub mod queue; pub mod skills; diff --git a/src/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index 4d80828..4b122a5 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -73,6 +73,10 @@ "agent_creation_prompt": { "type": "string", "description": "Optional prompt for generating an operator agent for this issuetype via 'claude -p' for this prompt. Should instruct Claude to output ONLY the agent system prompt. If omitted, no operator agent will be generated for this issuetype." + }, + "agent": { + "type": "string", + "description": "Default delegator name for this issuetype (overridden by step-level agent)" } }, "definitions": { @@ -276,6 +280,10 @@ "type": "array", "items": { "type": "string" }, "description": "File glob patterns in the worktree that signal this step is complete (e.g., '.tickets/plans/*.md')" + }, + "agent": { + "type": "string", + "description": "Delegator name override for this step" } } }, diff --git a/src/templates/schema.rs b/src/templates/schema.rs index ad6a20c..5f4f5a4 100644 --- a/src/templates/schema.rs +++ b/src/templates/schema.rs @@ -35,6 +35,9 @@ pub struct TemplateSchema { /// Prompt for generating this issue type's operator agent via `claude -p` #[serde(default)] pub agent_prompt: Option, + /// Default delegator name for this issuetype (overridden by step.agent) + #[serde(default)] + pub agent: Option, } fn default_true() -> bool { diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index d5f1ccc..98f6196 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] +use std::time::Instant; + use ratatui::{ layout::{Constraint, Direction, Layout}, Frame, @@ -32,10 +34,16 @@ pub struct Dashboard { pub backstage_status: ServerStatus, /// REST API server status pub rest_api_status: RestApiStatus, + /// Wrapper display name for header bar + pub wrapper_name: &'static str, /// Exit confirmation mode (first Ctrl+C pressed) pub exit_confirmation_mode: bool, /// Version update available (if notification should be shown) pub update_available_version: Option, + /// Transient status message (auto-dismissed after 5s) + pub status_message: Option, + /// When the status message was set + pub status_message_at: Option, } impl Dashboard { @@ -48,10 +56,13 @@ impl Dashboard { focused: FocusedPanel::Queue, paused: false, max_agents: config.effective_max_agents(), + wrapper_name: config.sessions.wrapper.display_name(), backstage_status: ServerStatus::Stopped, rest_api_status: RestApiStatus::Stopped, exit_confirmation_mode: false, update_available_version: None, + status_message: None, + status_message_at: None, } } @@ -71,6 +82,22 @@ impl Dashboard { self.update_available_version = version; } + /// Set a transient status message (auto-dismissed after 5 seconds) + pub fn set_status(&mut self, msg: &str) { + self.status_message = Some(msg.to_string()); + self.status_message_at = Some(Instant::now()); + } + + /// Clear status message if it has expired (5 second TTL) + pub fn clear_expired_status(&mut self) { + if let Some(at) = self.status_message_at { + if at.elapsed() > std::time::Duration::from_secs(5) { + self.status_message = None; + self.status_message_at = None; + } + } + } + pub fn update_queue(&mut self, tickets: Vec) { self.queue_panel.tickets = tickets; } @@ -106,6 +133,7 @@ impl Dashboard { // Header let header = HeaderBar { version: env!("CARGO_PKG_VERSION"), + wrapper_name: self.wrapper_name, }; header.render(frame, chunks[0]); @@ -152,6 +180,7 @@ impl Dashboard { rest_api_status: self.rest_api_status.clone(), exit_confirmation_mode: self.exit_confirmation_mode, update_available_version: self.update_available_version.clone(), + status_message: self.status_message.clone(), }; status.render(frame, chunks[2]); } @@ -317,6 +346,53 @@ impl Dashboard { #[cfg(test)] mod tests { + use super::*; + use std::time::{Duration, Instant}; + + fn make_test_dashboard() -> Dashboard { + // Minimal config for testing + let config = Config::default(); + Dashboard::new(&config) + } + + #[test] + fn test_set_status_stores_message() { + let mut dashboard = make_test_dashboard(); + assert!(dashboard.status_message.is_none()); + + dashboard.set_status("Test message"); + assert_eq!(dashboard.status_message.as_deref(), Some("Test message")); + assert!(dashboard.status_message_at.is_some()); + } + + #[test] + fn test_clear_expired_status_keeps_fresh_message() { + let mut dashboard = make_test_dashboard(); + dashboard.set_status("Fresh message"); + + dashboard.clear_expired_status(); + assert!(dashboard.status_message.is_some()); + } + + #[test] + fn test_clear_expired_status_clears_old_message() { + let mut dashboard = make_test_dashboard(); + dashboard.status_message = Some("Old message".to_string()); + // Set timestamp to 6 seconds ago + dashboard.status_message_at = Some(Instant::now() - Duration::from_secs(6)); + + dashboard.clear_expired_status(); + assert!(dashboard.status_message.is_none()); + assert!(dashboard.status_message_at.is_none()); + } + + #[test] + fn test_clear_expired_status_noop_when_no_message() { + let mut dashboard = make_test_dashboard(); + dashboard.clear_expired_status(); + assert!(dashboard.status_message.is_none()); + } + #[test] fn test_version_matches_cargo_toml() { // env! is evaluated at compile time from Cargo.toml diff --git a/src/ui/dialogs/confirm.rs b/src/ui/dialogs/confirm.rs index f3b5c65..d926880 100644 --- a/src/ui/dialogs/confirm.rs +++ b/src/ui/dialogs/confirm.rs @@ -102,6 +102,20 @@ pub struct ConfirmDialog { pub selected_project: usize, /// The original project from the ticket (for display reference) pub original_project: String, + + /// Session placement preview (populated before showing dialog) + pub session_preview: Option, +} + +/// Preview of where the agent session will land +#[derive(Debug, Clone)] +pub struct SessionPlacementPreview { + /// Wrapper type name (e.g., "cmux", "tmux") + pub wrapper_type: String, + /// Human-readable placement description (e.g., "auto -> same window") + pub placement_description: String, + /// Key-value pairs with target details + pub target_info: Vec<(String, String)>, } impl ConfirmDialog { @@ -121,6 +135,7 @@ impl ConfirmDialog { project_options: Vec::new(), selected_project: 0, original_project: String::new(), + session_preview: None, } } @@ -308,11 +323,13 @@ impl ConfirmDialog { // Calculate dialog height based on options let has_options = self.has_options(); + let has_session_preview = self.session_preview.is_some(); + let session_preview_height: u16 = if has_session_preview { 3 } else { 0 }; let base_height = if has_options { 60 } else { 45 }; let dialog_height = if show_priority { - base_height + base_height + session_preview_height } else { - base_height - 2 + base_height - 2 + session_preview_height }; // Center the dialog @@ -337,12 +354,13 @@ impl ConfirmDialog { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(2), // Type/ID - Constraint::Min(3), // Summary - Constraint::Length(2), // Project - Constraint::Length(priority_height), // Priority (conditional) - Constraint::Length(options_height), // Options (if any) - Constraint::Length(3), // Buttons + Constraint::Length(2), // Type/ID + Constraint::Min(3), // Summary + Constraint::Length(2), // Project + Constraint::Length(priority_height), // Priority (conditional) + Constraint::Length(session_preview_height), // Session target (conditional) + Constraint::Length(options_height), // Options (if any) + Constraint::Length(3), // Buttons ]) .margin(1) .split(inner); @@ -393,9 +411,29 @@ impl ConfirmDialog { frame.render_widget(Paragraph::new(priority_line), chunks[3]); } + // Render session placement preview if available + if let Some(ref preview) = self.session_preview { + let mut preview_spans = vec![ + Span::styled("Target: ", Style::default().fg(Color::Gray)), + Span::styled(&preview.wrapper_type, Style::default().fg(Color::Cyan)), + Span::styled( + format!(" ({})", preview.placement_description), + Style::default().fg(Color::DarkGray), + ), + ]; + for (key, value) in &preview.target_info { + preview_spans.push(Span::styled( + format!(" {key}: "), + Style::default().fg(Color::Gray), + )); + preview_spans.push(Span::styled(value, Style::default().fg(Color::White))); + } + frame.render_widget(Paragraph::new(Line::from(preview_spans)), chunks[4]); + } + // Render options section if any are available if has_options { - self.render_options(frame, chunks[4]); + self.render_options(frame, chunks[5]); } // Dim buttons when options are focused @@ -460,7 +498,7 @@ impl ConfirmDialog { }; let buttons_para = Paragraph::new(vec![buttons, hint]).alignment(Alignment::Center); - frame.render_widget(buttons_para, chunks[5]); + frame.render_widget(buttons_para, chunks[6]); } /// Render the options section (provider, project, docker, yolo toggles) diff --git a/src/ui/dialogs/help.rs b/src/ui/dialogs/help.rs index 404afcf..706905e 100644 --- a/src/ui/dialogs/help.rs +++ b/src/ui/dialogs/help.rs @@ -7,15 +7,20 @@ use ratatui::{ }; use super::centered_rect; +use crate::config::SessionWrapperType; use crate::ui::keybindings::{shortcuts_by_category_for_context, ShortcutContext}; pub struct HelpDialog { pub visible: bool, + pub wrapper_type: SessionWrapperType, } impl HelpDialog { - pub fn new() -> Self { - Self { visible: false } + pub fn new(wrapper_type: SessionWrapperType) -> Self { + Self { + visible: false, + wrapper_type, + } } pub fn toggle(&mut self) { @@ -100,6 +105,31 @@ impl HelpDialog { } } + // Zellij-specific reference keys + if self.wrapper_type == SessionWrapperType::Zellij { + help_text.push(Line::from("")); + help_text.push(Line::from(Span::styled( + "Zellij Keys (handled by Zellij, not Operator)", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Cyan), + ))); + let zellij_keys: &[(&str, &str)] = &[ + ("Ctrl+t ", "Tab mode (switch/create/close tabs)"), + ("Ctrl+p ", "Pane mode (split/move/resize panes)"), + ("Ctrl+o w", "Session manager"), + ("Ctrl+o f", "Toggle floating pane"), + ("Alt+n ", "New pane"), + ("Alt+←/→", "Switch tabs"), + ]; + for (key, desc) in zellij_keys { + help_text.push(Line::from(vec![ + Span::styled(format!("{key:<7}"), Style::default().fg(Color::Yellow)), + Span::raw(*desc), + ])); + } + } + // Footer help_text.push(Line::from("")); help_text.push(Line::from(Span::styled( @@ -126,7 +156,7 @@ mod tests { #[test] fn test_help_dialog_toggle() { - let mut dialog = HelpDialog::new(); + let mut dialog = HelpDialog::new(SessionWrapperType::default()); assert!(!dialog.visible); dialog.toggle(); @@ -138,7 +168,7 @@ mod tests { #[test] fn test_help_dialog_new_starts_hidden() { - let dialog = HelpDialog::new(); + let dialog = HelpDialog::new(SessionWrapperType::default()); assert!(!dialog.visible); } } diff --git a/src/ui/dialogs/mod.rs b/src/ui/dialogs/mod.rs index e01d083..71860ba 100644 --- a/src/ui/dialogs/mod.rs +++ b/src/ui/dialogs/mod.rs @@ -4,7 +4,9 @@ mod rejection; mod session_recovery; mod sync_confirm; -pub use confirm::{ConfirmDialog, ConfirmDialogFocus, ConfirmSelection, SelectedOption}; +pub use confirm::{ + ConfirmDialog, ConfirmDialogFocus, ConfirmSelection, SelectedOption, SessionPlacementPreview, +}; pub use help::HelpDialog; pub use rejection::{RejectionDialog, RejectionResult}; pub use session_recovery::{SessionRecoveryDialog, SessionRecoverySelection}; diff --git a/src/ui/keybindings.rs b/src/ui/keybindings.rs index 7190d74..4def662 100644 --- a/src/ui/keybindings.rs +++ b/src/ui/keybindings.rs @@ -177,6 +177,20 @@ pub static SHORTCUTS: &[Shortcut] = &[ category: ShortcutCategory::Navigation, context: ShortcutContext::Global, }, + Shortcut { + key: KeyCode::Char('h'), + alt_key: Some(KeyCode::Left), + description: "Previous panel", + category: ShortcutCategory::Navigation, + context: ShortcutContext::Global, + }, + Shortcut { + key: KeyCode::Char('l'), + alt_key: Some(KeyCode::Right), + description: "Next panel", + category: ShortcutCategory::Navigation, + context: ShortcutContext::Global, + }, // Actions Shortcut { key: KeyCode::Enter, @@ -194,7 +208,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('L'), - alt_key: Some(KeyCode::Char('l')), + alt_key: None, description: "Launch selected ticket", category: ShortcutCategory::Actions, context: ShortcutContext::Global, @@ -248,6 +262,13 @@ pub static SHORTCUTS: &[Shortcut] = &[ category: ShortcutCategory::Actions, context: ShortcutContext::Global, }, + Shortcut { + key: KeyCode::Char('F'), + alt_key: None, + description: "Focus cmux window", + category: ShortcutCategory::Actions, + context: ShortcutContext::Global, + }, // Dialogs Shortcut { key: KeyCode::Char('C'), diff --git a/src/ui/panels.rs b/src/ui/panels.rs index 4210c8e..87c5b7e 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -182,6 +182,15 @@ impl AgentsPanel { Color::Reset }; + // Wrapper badge: C=cmux, T=tmux, Z=zellij, V=vscode + let wrapper_badge = match a.session_wrapper.as_deref() { + Some("cmux") => "C", + Some("tmux") => "T", + Some("zellij") => "Z", + Some("vscode") => "V", + _ => " ", + }; + // Get the current step display text let step_display = a .current_step @@ -221,6 +230,12 @@ impl AgentsPanel { )); } + // Wrapper badge + line1_spans.push(Span::styled( + wrapper_badge, + Style::default().fg(Color::DarkGray), + )); + line1_spans.extend(vec![ Span::styled(status_icon, Style::default().fg(status_color)), Span::raw(" "), @@ -229,19 +244,37 @@ impl AgentsPanel { Span::styled(step_display, Style::default().fg(Color::Cyan)), ]); - let mut lines = vec![ - Line::from(line1_spans), - Line::from(vec![ - Span::raw(" "), - Span::styled( - format_display_id(&a.ticket_id), - Style::default().fg(Color::Gray), - ), - Span::raw(" "), - Span::styled(elapsed_display, Style::default().fg(Color::DarkGray)), - ]), + // Build line 2: ticket ID, elapsed, and cmux refs if applicable + let mut line2_spans = vec![ + Span::raw(" "), + Span::styled( + format_display_id(&a.ticket_id), + Style::default().fg(Color::Gray), + ), + Span::raw(" "), + Span::styled(elapsed_display, Style::default().fg(Color::DarkGray)), ]; + // Add cmux workspace/window refs (abbreviated to first 6 chars) + if a.session_wrapper.as_deref() == Some("cmux") { + if let Some(ref ws_ref) = a.session_context_ref { + let abbrev = &ws_ref[..ws_ref.len().min(6)]; + line2_spans.push(Span::styled( + format!(" ws:{abbrev}"), + Style::default().fg(Color::DarkGray), + )); + } + if let Some(ref win_ref) = a.session_window_ref { + let abbrev = &win_ref[..win_ref.len().min(6)]; + line2_spans.push(Span::styled( + format!(" win:{abbrev}"), + Style::default().fg(Color::DarkGray), + )); + } + } + + let mut lines = vec![Line::from(line1_spans), Line::from(line2_spans)]; + // Add review hint line for agents awaiting review if a.status == "awaiting_input" { let hint = match a.review_state.as_deref() { @@ -391,6 +424,15 @@ impl AwaitingPanel { .agents .iter() .map(|a| { + // Wrapper badge + let wrapper_badge = match a.session_wrapper.as_deref() { + Some("cmux") => "C", + Some("tmux") => "T", + Some("zellij") => "Z", + Some("vscode") => "V", + _ => " ", + }; + // Get the current step display text let step_display = a .current_step @@ -398,8 +440,34 @@ impl AwaitingPanel { .map(|s| format!("[{s}]")) .unwrap_or_default(); + // Build line 2 with optional cmux refs + let mut line2_spans = vec![ + Span::raw(" "), + Span::styled( + a.last_message.as_deref().unwrap_or("Awaiting input..."), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::ITALIC), + ), + ]; + + // Add cmux refs for cmux agents + if a.session_wrapper.as_deref() == Some("cmux") { + if let Some(ref ws_ref) = a.session_context_ref { + let abbrev = &ws_ref[..ws_ref.len().min(6)]; + line2_spans.push(Span::styled( + format!(" ws:{abbrev}"), + Style::default().fg(Color::DarkGray), + )); + } + } + let lines = vec![ Line::from(vec![ + Span::styled( + wrapper_badge.to_string(), + Style::default().fg(Color::DarkGray), + ), Span::styled("⏸ ", Style::default().fg(Color::Yellow)), Span::styled(&a.project, Style::default().add_modifier(Modifier::BOLD)), Span::raw(" "), @@ -410,15 +478,7 @@ impl AwaitingPanel { Style::default().fg(Color::Gray), ), ]), - Line::from(vec![ - Span::raw(" "), - Span::styled( - a.last_message.as_deref().unwrap_or("Awaiting input..."), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::ITALIC), - ), - ]), + Line::from(line2_spans), ]; ListItem::new(lines) @@ -497,6 +557,7 @@ pub struct StatusBar { pub rest_api_status: RestApiStatus, pub exit_confirmation_mode: bool, pub update_available_version: Option, + pub status_message: Option, } impl StatusBar { @@ -597,7 +658,19 @@ impl StatusBar { let help = Self::build_hints(area.width); - let content = Line::from(vec![status, agents, web_indicator, help]); + let mut spans = vec![status, agents, web_indicator]; + + // Show transient status message if present + if let Some(ref msg) = self.status_message { + spans.push(Span::styled( + format!(" {msg}"), + Style::default().fg(Color::Yellow), + )); + } + + spans.push(help); + + let content = Line::from(spans); let bar = Paragraph::new(content).block(Block::default().borders(Borders::TOP)); @@ -607,6 +680,7 @@ impl StatusBar { pub struct HeaderBar { pub version: &'static str, + pub wrapper_name: &'static str, } impl HeaderBar { @@ -622,6 +696,10 @@ impl HeaderBar { format!(" v{}", self.version), Style::default().fg(Color::Gray), ), + Span::styled( + format!(" \u{2502} {}", self.wrapper_name), + Style::default().fg(Color::DarkGray), + ), ]; let content = Line::from(spans); diff --git a/src/ui/session_preview.rs b/src/ui/session_preview.rs index b442f17..469369d 100644 --- a/src/ui/session_preview.rs +++ b/src/ui/session_preview.rs @@ -149,46 +149,94 @@ impl SessionPreview { let inner_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // Status line + Constraint::Length(2), // Status lines (2 rows) Constraint::Min(5), // Content Constraint::Length(1), // Help line ]) .split(inner_area); - // Status line - let status_text = if let Some(a) = &self.agent { - if let Some(session) = &a.session_name { - format!( - "Session: {} | Status: {} | Scroll: {}/{}", - session, - a.status, - self.scroll + 1, - self.total_lines.max(1) - ) + // Status lines (2 rows) + let (status_line1, status_line2) = if let Some(a) = &self.agent { + // Line 1: wrapper tag, session name, cmux refs + let wrapper_tag = match a.session_wrapper.as_deref() { + Some("cmux") => "[cmux]", + Some("tmux") => "[tmux]", + Some("zellij") => "[zellij]", + Some("vscode") => "[vscode]", + _ => "[--]", + }; + + let mut line1 = if let Some(session) = &a.session_name { + format!("{wrapper_tag} Session: {session}") } else { - format!("Status: {} | No session attached", a.status) + format!("{wrapper_tag} No session attached") + }; + + // Append cmux refs + if a.session_wrapper.as_deref() == Some("cmux") { + if let Some(ref ws) = a.session_context_ref { + line1.push_str(&format!(" | WS: {ws}")); + } + if let Some(ref win) = a.session_window_ref { + line1.push_str(&format!(" | Win: {win}")); + } + } + + // Line 2: step, review state, status, scroll + let mut line2_parts = Vec::new(); + if let Some(ref step) = a.current_step { + line2_parts.push(format!("Step: {step}")); } + if let Some(ref review) = a.review_state { + line2_parts.push(format!("Review: {review}")); + } + line2_parts.push(format!("Status: {}", a.status)); + line2_parts.push(format!( + "Scroll: {}/{}", + self.scroll + 1, + self.total_lines.max(1) + )); + + (line1, line2_parts.join(" | ")) } else { - "No agent selected".to_string() + ("No agent selected".to_string(), String::new()) }; - let status = Paragraph::new(status_text) - .style(Style::default().fg(Color::Gray)) - .alignment(Alignment::Left); + let status = Paragraph::new(vec![ + Line::from(Span::styled(status_line1, Style::default().fg(Color::Gray))), + Line::from(Span::styled( + status_line2, + Style::default().fg(Color::DarkGray), + )), + ]) + .alignment(Alignment::Left); frame.render_widget(status, inner_chunks[0]); // Content area if let Some(ref err) = self.error { - let error_text = Paragraph::new(vec![ + let mut error_lines = vec![ Line::from(Span::styled( "Failed to capture session content:", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::styled(err.as_str(), Style::default().fg(Color::Red))), - ]) - .block(Block::default().borders(Borders::TOP)) - .alignment(Alignment::Center); + ]; + + // Add actionable hints for cmux errors + if let Some(ref a) = self.agent { + if a.session_wrapper.as_deref() == Some("cmux") { + error_lines.push(Line::from("")); + error_lines.push(Line::from(Span::styled( + "Hint: Workspace may have been closed externally. Try relaunching.", + Style::default().fg(Color::Yellow), + ))); + } + } + + let error_text = Paragraph::new(error_lines) + .block(Block::default().borders(Borders::TOP)) + .alignment(Alignment::Center); frame.render_widget(error_text, inner_chunks[1]); } else if self.content.is_empty() { let empty_text = Paragraph::new("(Session content is empty)") diff --git a/src/ui/setup/steps/wrapper.rs b/src/ui/setup/steps/wrapper.rs index 42f3abe..7a2f0a8 100644 --- a/src/ui/setup/steps/wrapper.rs +++ b/src/ui/setup/steps/wrapper.rs @@ -578,7 +578,7 @@ impl SetupScreen { // Instructions let instructions = vec![ Line::from(Span::styled( - "Zellij is a terminal workspace manager:", + "How Operator uses Zellij:", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), @@ -586,15 +586,19 @@ impl SetupScreen { Line::from(""), Line::from(vec![ Span::styled(" - ", Style::default().fg(Color::Cyan)), - Span::raw("Agents run in Zellij panes"), + Span::raw("Agent launches open dedicated Zellij tabs"), ]), Line::from(vec![ Span::styled(" - ", Style::default().fg(Color::Cyan)), - Span::raw("Operator must be running inside Zellij"), + Span::raw("Press Enter on an agent to jump to its tab"), ]), Line::from(vec![ Span::styled(" - ", Style::default().fg(Color::Cyan)), - Span::raw("Each agent gets its own named pane"), + Span::raw("Press V to preview agent output without leaving the dashboard"), + ]), + Line::from(vec![ + Span::styled(" - ", Style::default().fg(Color::Cyan)), + Span::raw("Tab names appear as op:: in your tab bar"), ]), Line::from(""), Line::from(Span::styled( diff --git a/vscode-extension/.fantasticonrc.js b/vscode-extension/.fantasticonrc.js index a13c3de..e8c1811 100644 --- a/vscode-extension/.fantasticonrc.js +++ b/vscode-extension/.fantasticonrc.js @@ -1,6 +1,6 @@ /** @type {import('fantasticon').RunnerOptions} */ module.exports = { - inputDir: './images/icons', + inputDir: '../icons', outputDir: './images/icons/dist', name: 'operator-icons', fontTypes: ['woff'], diff --git a/vscode-extension/images/icons/dist/operator-icons.css b/vscode-extension/images/icons/dist/operator-icons.css new file mode 100644 index 0000000..51890bf --- /dev/null +++ b/vscode-extension/images/icons/dist/operator-icons.css @@ -0,0 +1,55 @@ +@font-face { + font-family: "operator-icons"; + src: url("./operator-icons.woff?17728809643116deb872390fbf63aeea") format("woff"); +} + +i[class^="opi-"]:before, i[class*=" opi-"]:before { + font-family: operator-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.opi-zellij:before { + content: "\f101"; +} +.opi-webhook:before { + content: "\f102"; +} +.opi-vscode:before { + content: "\f103"; +} +.opi-tmux:before { + content: "\f104"; +} +.opi-notification:before { + content: "\f105"; +} +.opi-linear:before { + content: "\f106"; +} +.opi-gitlab:before { + content: "\f107"; +} +.opi-github:before { + content: "\f108"; +} +.opi-gemini:before { + content: "\f109"; +} +.opi-codex:before { + content: "\f10a"; +} +.opi-cmux:before { + content: "\f10b"; +} +.opi-claude:before { + content: "\f10c"; +} +.opi-atlassian:before { + content: "\f10d"; +} diff --git a/vscode-extension/images/icons/dist/operator-icons.json b/vscode-extension/images/icons/dist/operator-icons.json new file mode 100644 index 0000000..a491878 --- /dev/null +++ b/vscode-extension/images/icons/dist/operator-icons.json @@ -0,0 +1,15 @@ +{ + "zellij": 61697, + "webhook": 61698, + "vscode": 61699, + "tmux": 61700, + "notification": 61701, + "linear": 61702, + "gitlab": 61703, + "github": 61704, + "gemini": 61705, + "codex": 61706, + "cmux": 61707, + "claude": 61708, + "atlassian": 61709 +} \ No newline at end of file diff --git a/vscode-extension/images/icons/dist/operator-icons.woff b/vscode-extension/images/icons/dist/operator-icons.woff new file mode 100644 index 0000000000000000000000000000000000000000..d2e3ba4ed40db7b4415a22524658f1e1ffc2008a GIT binary patch literal 3120 zcmY*bc{J4T_kYjW8C%FGvSl3^O!n+Mp$v+AWK0+eLlYy)I+9)XHOf{AMY3mxlAW=u z>>7j+p)lX^`TfrKobNs7b)I`)&+ECbd!D~;ptZ3v00L+MQ2^LZ&Qs9;t55R(4HKlD zJ^+B2X{;s*80|m)z>g?s9ONQGD6eQdH^cdGYai6G?I3I;vq!Cd9Wi$1>=pw(>Q5bEl7hJ zrw(=RjxYK|cj9~Q1gCv?4ZTr#nlCG@Cm>9N2uK3x_Ca~O(YPquJTwomGfNe#4%YWB z4glEdX&PEzpy*?VBqu%(?Q+Y-W!6=}r5f$x@Ar!ZHQQq**M&}YbwPyz$(NLJ6{Qeb zDdBXV=(^v8d2)!jN5+5wNlgY*;N&fe*CVg3t-GzQI%)1y@<;@k2f7ap5rAevuV)KX zM}%D*=Z&Tl7SP#NS_K-&@f(nM-5hQsZXQ4g%#waGNZxwN7j`;P2>qkT1?NFCSyR0O zb5GAiR(WR?w+=FX>7&RkI#_a~$rX_nGim3!(iM;X75;*nvd_Xtt>RKkEd8(C}Mj-b0fbVCn@6n5!3H^zZT}V<`NgrAJIRI6O8WUKf?>1nPHe4$*=g!D2_JG?2- zH#5G5Y^J}f1QW~CAMTEgDY$3eEmmGy-5eEUwz9Ue?B%Ra->8JVURB={m*`=Xp+fhy zf0B13lbn`SfDT64Gqo^>FipXMZI|4a{r5 zO8g*lfj~BsrjMr>eE7;2RO*&qRsY!7-wft76+&_>oc~SY^}GQGg$ZJj>?2PAZGCp1 zr&bc&*2E_gM1}mm4cX?S!z$lB9_y5%j4t4ms%z0fGRv~zhk1<6jDOvInU&05%q?fK zI3Zl@wqi_)EqZZXW_$9YXt$_55KAH}*6u_YOJZ>r;U!!e-CcsOh=d1g+(FUj)aWlf zHcc_ytOny6!ot+!R*l z{zQNZp6)0rfAm5qxqajMTcH7$FC0=8lq8 z&aIi|q=Zq1fiZ()?H{Lu(9h3pyH4-GLEkdA_@4N?bz&YRb#x|IQlRIfV8+44Tov2k zvkhn1?6L7P{`wn3p0JR!_nVDJ%HXJwf zh6lPvsx4u!s@%G1{>sUXFND>!r#|0{sdfkQkl7`H|4SWDz3ubG*JdW%MFC(!%y_cX zdeEm-`=)ST+-%e1Er|__9|s&=w~Z~_5aV0y+vj-JFJ2W`EzXx{U0RP%3lJj>*r+NN zEObmsU71QqAB{HXzndn`-URLvk)EkJcc$|K{yi{IeL57)F~QyRY(7M|I`xrUj?2Y_ z&+0R=hv3TgD3C04gR`PBwAW<+udB{NL z;SRIwPjPtkMCda0aEpmcLs(t3qv}}t_&BLWdi!{;Gl?pnFEWetju3zp_xfn|b?Hyc zF-Ij3*=9GLtOhsBzKbG1m8V{%n|@mIJ&^HZ`NI1pDYvszmjZKA6m8BJObQGfYVI^w zE5cQ0)FNK>7Ab~$#MLZ;)!4F>-@RZve%))Fp+G!Zo0=xR9txnqe>P1AlS5URXBhD< zPPWulp9CKvNPmq^i|=Bv^R7Jc%iPXAy9*PCsnqH$y$kpCogDnVl(TI{c^0Dv50Fvv zz$%@sar;>0g+b%FaGp|5XbzL-zUID$Su&2&|9#hyqShFRm(n=Bd56k0|I1n{t;7;s zSsD2~FW2%6>-S5G@pA5RYEtTZ8F9NkJeve7BTuKb+6A*OslX+)zb?M7e%bmo( zYrQ67ZnfKY{K9_`=`%fJ_~`Z!9}te2ORLq(f)(BVS{gGxx60J++-EW>Q*>2TsU%&e zk3TTI+|jUKi+4B-yCBbRIjY%(XfL*xI~#FpN0pm6-ORyOAQ#^sd%rCW2c{VVMpwvgfJ=t66=ld24*` zy13dm?{t<|BCVPi)O}Pi7UNVrH8kt-hX|=Ubt~j>6qHH(uY}?6(VErMk{H={wEaI{TQ9q>Yt0!OJc# zT3E_ZGT%1zZr+}D3Odi!VI*2N zwA6Of=~~flxh!AZjSRN9ZM!jVS%i%IWVZn;Iqh}>q|OO??!HKq=E6E|*=zQY-737{ zJ&AQ_%KlWixKMjq9A0^0`+4F6yyytdH>uvdu>CzF$?NjWWP4{RBRgY}{g_qP|=OCQv)KFOSEgUXO?l&S*%PSg~vHfld z^x8qaU$s#;8P949`e0(!16y+18XJhp`F57=MA+i2x9w`HaT2VpPTsxzCyekx_owP* zJ7272-+=bk*#7b2E6bpD8PQ|5pjwv4|G&!s^9^#=-#dPN32~cs9|6TGjFkIe!iK1! zRO$fcb2;0hzk))zA;_4Qje4?w{JZ~*pj?2I6Gb29bKB=I)_uTzoj&E?y#Vb(i;)BB z)2mE|=;~1MiO>5~9YCa&Rf~2FfFNKnSR0H5KLd}@f$2i%3h3?WqZ#BFoc`N;7Cq6~ z)&qbq>-I84K_FCO2qPf$0JKT-evsVfMbhOZSRcZaudtT<(*TvmAuo@V;9k#?3Bd_G zp{7bGCDv9w;bQB-N6T+H$z{u3J2ry^ghI%k=AL9bP~XW99)Y#x>--pPV}GUN^W+@i z#-hiZiavy(x#LUvr`c6fa2~|;P!#ixyt66))rI;9n|h3_H#$D;v%sQ5650VX2fksx zPZD(o-!}Ko?4LN6S2R){&o!uPPNt0NfAR8QRgPz*5N-MIy%_hpXEW-D@RzrHq1=#@ zMOu+VysUMv;^Ftshjsep#U`CfZ7+<9{&*_!&QK=@%L`qCdMf*j7}~2Uz`5tN_6y9a zT=a#~W1E8MD?aO%zKt)Q2`e9Y z7!uB~J3?`4g`M0|5H)lMq1PmDICw9^lVuL&JQVGaQ~TX}Q*_1df)x9-+AhiZ?(iah i>fNH4!|3fM_ziRqx?): void { + if (ConfigPanel.currentPanel) { + void ConfigPanel.currentPanel._panel.webview.postMessage({ + type: 'navigateTo', + section, + prefill, + }); + } + } + private _getHtmlContent(): string { const webview = this._panel.webview; diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 1c2b2f1..9118d17 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -42,6 +42,7 @@ import { initializeTicketsDirectory, } from './walkthrough'; import { addJiraProject, addLinearTeam } from './kanban-onboarding'; +import { startGitOnboarding, onboardGitHub, onboardGitLab } from './git-onboarding'; import { ConfigPanel } from './config-panel'; import { configFileExists } from './config-paths'; import { connectMcpServer } from './mcp-connect'; @@ -68,6 +69,7 @@ function showConfigMissingNotification(): void { let terminalManager: TerminalManager; let webhookServer: WebhookServer; let statusBarItem: vscode.StatusBarItem; +let createBarItem: vscode.StatusBarItem; let launchManager: LaunchManager; let issueTypeService: IssueTypeService; @@ -116,8 +118,20 @@ export async function activate( statusBarItem.command = 'operator.showStatus'; context.subscriptions.push(statusBarItem); + // Create "New" status bar item + createBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 99 + ); + createBarItem.text = '$(add) New'; + createBarItem.tooltip = 'Create new delegator, issue type, or project'; + createBarItem.command = 'operator.showCreateMenu'; + createBarItem.show(); + context.subscriptions.push(createBarItem); + // Create TreeView providers (with issue type service) statusProvider = new StatusTreeProvider(context); + statusProvider.setWebhookServer(webhookServer); inProgressProvider = new TicketTreeProvider('in-progress', issueTypeService, terminalManager); queueProvider = new TicketTreeProvider('queue', issueTypeService); completedProvider = new TicketTreeProvider('completed', issueTypeService); @@ -241,6 +255,26 @@ export async function activate( 'operator.startKanbanOnboarding', () => startKanbanOnboarding(extensionContext) ), + vscode.commands.registerCommand( + 'operator.startGitOnboarding', + () => startGitOnboarding().then(() => refreshAllProviders()) + ), + vscode.commands.registerCommand( + 'operator.configureGitHub', + () => onboardGitHub().then(() => refreshAllProviders()) + ), + vscode.commands.registerCommand( + 'operator.configureGitLab', + () => onboardGitLab().then(() => refreshAllProviders()) + ), + vscode.commands.registerCommand( + 'operator.showCreateMenu', + showCreateMenu + ), + vscode.commands.registerCommand( + 'operator.openCreateDelegator', + (tool?: string, model?: string) => openCreateDelegator(tool, model) + ), vscode.commands.registerCommand( 'operator.detectLlmTools', () => detectLlmTools(extensionContext, getOperatorPath) @@ -489,9 +523,15 @@ async function startServer(): Promise { } if (webhookServer.isRunning()) { + // Re-register session file if it was lost (fixes status showing "Stopped") + const ticketsDir = await findTicketsDir(); + if (ticketsDir) { + await webhookServer.ensureSessionFile(ticketsDir); + } void vscode.window.showInformationMessage( - 'Operator webhook server already running' + `Webhook connected on port ${webhookServer.getPort()}` ); + await refreshAllProviders(); return; } @@ -516,6 +556,7 @@ async function startServer(): Promise { } updateStatusBar(); + await refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; void vscode.window.showErrorMessage(`Failed to start webhook server: ${msg}`); @@ -591,6 +632,7 @@ async function launchTicketCommand(treeItem?: TicketItem): Promise { } await launchManager.launchTicket(ticket, { + delegator: null, model: 'sonnet', yoloMode: false, resumeSession: false, @@ -696,6 +738,7 @@ async function launchTicketFromEditorCommand(): Promise { // Launch via Operator API try { const response = await apiClient.launchTicket(metadata.id, { + delegator: null, provider: null, wrapper: 'vscode', model: 'sonnet', @@ -783,6 +826,7 @@ async function launchTicketFromEditorWithOptionsCommand(): Promise { // Launch via Operator API try { const response = await apiClient.launchTicket(metadata.id, { + delegator: options.delegator ?? null, provider: null, wrapper: 'vscode', model: options.model, @@ -1245,6 +1289,51 @@ async function revealTicketsDirCommand(): Promise { await vscode.commands.executeCommand('revealFileInOS', uri); } +/** + * Command: Show "Create New" menu + */ +async function showCreateMenu(): Promise { + const choice = await vscode.window.showQuickPick( + [ + { label: '$(rocket) New Delegator', detail: 'delegator', description: 'Create a tool+model pairing for autonomous launches' }, + { label: '$(list-tree) New Issue Type', detail: 'issuetype', description: 'Define a custom issue type with steps' }, + { label: '$(project) New Managed Project', detail: 'project', description: 'Assess and register a project' }, + ], + { + title: 'Create New', + placeHolder: 'What would you like to create?', + } + ); + + if (!choice) { return; } + + switch (choice.detail) { + case 'delegator': + openCreateDelegator(); + break; + case 'issuetype': + ConfigPanel.createOrShow(extensionContext.extensionUri); + ConfigPanel.navigateTo('section-kanban', { action: 'createIssueType' }); + break; + case 'project': + ConfigPanel.createOrShow(extensionContext.extensionUri); + ConfigPanel.navigateTo('section-projects'); + break; + } +} + +/** + * Command: Open delegator creation, optionally pre-filled with tool+model + */ +function openCreateDelegator(tool?: string, model?: string): void { + ConfigPanel.createOrShow(extensionContext.extensionUri); + ConfigPanel.navigateTo('section-agents', { + action: 'createDelegator', + tool, + model, + }); +} + /** * Extension deactivation */ diff --git a/vscode-extension/src/git-onboarding.ts b/vscode-extension/src/git-onboarding.ts new file mode 100644 index 0000000..746ed35 --- /dev/null +++ b/vscode-extension/src/git-onboarding.ts @@ -0,0 +1,293 @@ +/** + * Git Provider Onboarding for Operator VS Code extension + * + * Guides users through connecting GitHub or GitLab as their git provider. + * Auto-detects CLI tools (gh, glab) for silent token grab, falls back to + * manual PAT entry. Smart-merges config into config.toml preserving + * existing settings like branch_format and use_worktrees. + */ + +import * as vscode from 'vscode'; +import * as fs from 'fs/promises'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getConfigDir, getResolvedConfigPath, resolveWorkingDirectory } from './config-paths'; +import { showEnvVarInstructions } from './kanban-onboarding'; + +const execAsync = promisify(exec); + +/** + * Detect a CLI tool in PATH, return its path or null + */ +async function findCliTool(tool: string): Promise { + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + try { + const { stdout } = await execAsync(`${whichCmd} ${tool}`); + return stdout.trim().split('\n')[0] ?? null; + } catch { + return null; + } +} + +/** + * Try to get a token from a CLI tool (gh auth token, glab auth token) + */ +async function getCliToken(command: string): Promise { + try { + const { stdout } = await execAsync(command); + const token = stdout.trim(); + return token || null; + } catch { + return null; + } +} + +/** + * Smart-merge git config into config.toml. + * + * Reads existing config, updates [git] and provider sub-sections, + * preserves branch_format and use_worktrees if already set. + */ +async function writeGitConfig( + provider: string, + providerSection: Record +): Promise { + try { + const configDir = getConfigDir(resolveWorkingDirectory()); + await fs.mkdir(configDir, { recursive: true }); + } catch { + // directory may already exist + } + + const configPath = getResolvedConfigPath(); + let existing = ''; + try { + existing = await fs.readFile(configPath, 'utf-8'); + } catch { + // file doesn't exist yet + } + + try { + const { parse, stringify } = await import('smol-toml'); + const config = existing.trim() ? parse(existing) as Record : {}; + + // Preserve existing git settings + const existingGit = (config.git ?? {}) as Record; + const mergedGit: Record = { + ...existingGit, + provider, + }; + + // Merge provider sub-section + const existingProvider = (existingGit[provider] ?? {}) as Record; + mergedGit[provider] = { ...existingProvider, ...providerSection }; + + config.git = mergedGit; + const output = stringify(config); + await fs.writeFile(configPath, output, 'utf-8'); + return true; + } catch (err) { + void vscode.window.showErrorMessage( + `Failed to write git config: ${err instanceof Error ? err.message : String(err)}` + ); + return false; + } +} + +/** + * GitHub onboarding flow + * + * 1. Detect gh CLI → grab token silently + * 2. Fall back to manual PAT input + * 3. Validate via GitHub API + * 4. Write config + */ +export async function onboardGitHub(): Promise { + let token: string | undefined; + + // Try gh CLI first + const ghPath = await findCliTool('gh'); + if (ghPath) { + token = await getCliToken('gh auth token') ?? undefined; + if (token) { + void vscode.window.showInformationMessage('Found GitHub token from gh CLI.'); + } + } + + // Fall back to manual input + if (!token) { + const message = ghPath + ? 'gh CLI found but not authenticated. Enter a GitHub Personal Access Token:' + : 'Enter a GitHub Personal Access Token (or install gh CLI for auto-detection):'; + + token = await vscode.window.showInputBox({ + title: 'GitHub Authentication', + prompt: message, + password: true, + ignoreFocusOut: true, + placeHolder: 'ghp_...', + }) ?? undefined; + + if (!token) { return; } + } + + // Validate token + const user = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Validating GitHub token...' }, + async () => { + try { + const response = await fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) { + return await response.json() as { login: string }; + } + } catch { + // validation failed + } + return null; + } + ); + + if (!user) { + void vscode.window.showErrorMessage('GitHub token validation failed. Check your token and try again.'); + return; + } + + // Write config + const written = await writeGitConfig('github', { + enabled: true, + token_env: 'GITHUB_TOKEN', + }); + if (!written) { return; } + + // Set env var for current session + process.env['GITHUB_TOKEN'] = token; + + void vscode.window.showInformationMessage( + `GitHub connected as ${user.login}! Config written to ${getResolvedConfigPath()}` + ); + + await showEnvVarInstructions([ + `export GITHUB_TOKEN=""`, + ]); +} + +/** + * GitLab onboarding flow + * + * 1. Ask for host (default gitlab.com) + * 2. Detect glab CLI → grab token silently + * 3. Fall back to manual PAT input + * 4. Validate via GitLab API + * 5. Write config + */ +export async function onboardGitLab(): Promise { + // Ask for host + const host = await vscode.window.showInputBox({ + title: 'GitLab Host', + prompt: 'Enter your GitLab instance URL', + value: 'gitlab.com', + ignoreFocusOut: true, + placeHolder: 'gitlab.com or your self-hosted domain', + }) ?? undefined; + + if (!host) { return; } + + let token: string | undefined; + + // Try glab CLI first + const glabPath = await findCliTool('glab'); + if (glabPath) { + token = await getCliToken('glab auth token') ?? undefined; + if (token) { + void vscode.window.showInformationMessage('Found GitLab token from glab CLI.'); + } + } + + // Fall back to manual input + if (!token) { + const message = glabPath + ? 'glab CLI found but not authenticated. Enter a GitLab Personal Access Token:' + : 'Enter a GitLab Personal Access Token (or install glab CLI for auto-detection):'; + + token = await vscode.window.showInputBox({ + title: 'GitLab Authentication', + prompt: message, + password: true, + ignoreFocusOut: true, + placeHolder: 'glpat-...', + }) ?? undefined; + + if (!token) { return; } + } + + // Validate token + const apiHost = host.includes('://') ? host : `https://${host}`; + const user = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Validating GitLab token...' }, + async () => { + try { + const response = await fetch(`${apiHost}/api/v4/user`, { + headers: { 'Private-Token': token }, + }); + if (response.ok) { + return await response.json() as { username: string }; + } + } catch { + // validation failed + } + return null; + } + ); + + if (!user) { + void vscode.window.showErrorMessage('GitLab token validation failed. Check your token and host, then try again.'); + return; + } + + // Write config + const written = await writeGitConfig('gitlab', { + enabled: true, + token_env: 'GITLAB_TOKEN', + host, + }); + if (!written) { return; } + + // Set env var for current session + process.env['GITLAB_TOKEN'] = token; + + void vscode.window.showInformationMessage( + `GitLab connected as ${user.username}! Config written to ${getResolvedConfigPath()}` + ); + + await showEnvVarInstructions([ + `export GITLAB_TOKEN=""`, + ]); +} + +/** + * Entry point: let user pick GitHub or GitLab, then route to the right flow + */ +export async function startGitOnboarding(): Promise { + const choice = await vscode.window.showQuickPick( + [ + { label: 'GitHub', description: 'Connect to github.com', detail: 'github' }, + { label: 'GitLab', description: 'Connect to gitlab.com or self-hosted', detail: 'gitlab' }, + { label: 'Skip', description: 'Configure later' }, + ], + { + title: 'Connect Git Provider', + placeHolder: 'Select a git hosting provider', + ignoreFocusOut: true, + } + ); + + if (!choice || choice.label === 'Skip') { return; } + + if (choice.detail === 'github') { + await onboardGitHub(); + } else if (choice.detail === 'gitlab') { + await onboardGitLab(); + } +} diff --git a/vscode-extension/src/issuetype-service.ts b/vscode-extension/src/issuetype-service.ts index 40b573b..9c2450f 100644 --- a/vscode-extension/src/issuetype-service.ts +++ b/vscode-extension/src/issuetype-service.ts @@ -12,7 +12,7 @@ import { IssueTypeSummary } from './generated'; /** * Default issue types used when API is unavailable */ -const DEFAULT_ISSUE_TYPES: IssueTypeSummary[] = [ +export const DEFAULT_ISSUE_TYPES: IssueTypeSummary[] = [ { key: 'FEAT', name: 'Feature', @@ -68,7 +68,7 @@ const DEFAULT_ISSUE_TYPES: IssueTypeSummary[] = [ /** * Map glyph characters to VSCode ThemeIcon names */ -const GLYPH_TO_ICON: Record = { +export const GLYPH_TO_ICON: Record = { '*': 'sparkle', '#': 'wrench', '>': 'tasklist', @@ -87,7 +87,7 @@ const GLYPH_TO_ICON: Record = { /** * Map color names to VSCode ThemeColor references */ -const COLOR_TO_THEME: Record = { +export const COLOR_TO_THEME: Record = { cyan: 'terminal.ansiCyan', red: 'terminal.ansiRed', green: 'terminal.ansiGreen', diff --git a/vscode-extension/src/kanban-onboarding.ts b/vscode-extension/src/kanban-onboarding.ts index 2ef489e..6cd0c0f 100644 --- a/vscode-extension/src/kanban-onboarding.ts +++ b/vscode-extension/src/kanban-onboarding.ts @@ -938,7 +938,13 @@ export async function addJiraProject( // Filter out already-configured projects const available = projects.filter((p) => !existingProjects.has(p.key)); if (available.length === 0) { - void vscode.window.showInformationMessage('All available projects are already configured.'); + const action = await vscode.window.showInformationMessage( + `All projects on ${domain} are already configured.`, + 'Connect Another Workspace' + ); + if (action === 'Connect Another Workspace') { + await vscode.commands.executeCommand('operator.startKanbanOnboarding'); + } return; } diff --git a/vscode-extension/src/launch-dialog.ts b/vscode-extension/src/launch-dialog.ts index 5a11663..d9d65b0 100644 --- a/vscode-extension/src/launch-dialog.ts +++ b/vscode-extension/src/launch-dialog.ts @@ -2,51 +2,119 @@ * Launch dialogs for Operator VS Code extension * * QuickPick dialogs for selecting tickets and launch options. + * Prefers delegators fetched from the Operator API; falls back + * to hardcoded Claude models when the API is unavailable. */ import * as vscode from 'vscode'; import { LaunchOptions, TicketInfo, ModelOption } from './types'; +import type { DelegatorResponse } from './generated/DelegatorResponse'; +import type { DelegatorsResponse } from './generated/DelegatorsResponse'; +import { discoverApiUrl } from './api-client'; interface TicketPickItem extends vscode.QuickPickItem { ticket: TicketInfo; } -interface ModelPickItem extends vscode.QuickPickItem { +interface DelegatorPickItem extends vscode.QuickPickItem { + delegatorName: string | undefined; model: ModelOption; } /** - * Show launch options dialog + * Fetch configured delegators from the Operator API. + * Returns an empty array if the API is unavailable. */ -export async function showLaunchOptionsDialog( - ticket: TicketInfo, - hasExistingSession: boolean -): Promise { - // Model selection - const modelItems: ModelPickItem[] = [ - { - label: 'sonnet', - description: 'Claude Sonnet (recommended)', - model: 'sonnet', - }, - { - label: 'opus', - description: 'Claude Opus (most capable)', - model: 'opus', - }, +async function fetchDelegators( + ticketsDir: string | undefined +): Promise { + try { + const apiUrl = await discoverApiUrl(ticketsDir); + const response = await fetch(`${apiUrl}/api/v1/delegators`); + if (response.ok) { + const data = (await response.json()) as DelegatorsResponse; + return data.delegators; + } + } catch { + // API not available + } + return []; +} + +/** + * Build delegator QuickPick items from API response. + * Includes an "Auto" default and falls back to hardcoded models when empty. + */ +function buildDelegatorItems( + delegators: DelegatorResponse[] +): DelegatorPickItem[] { + if (delegators.length === 0) { + // Fallback: hardcoded Claude models + return [ + { + label: 'sonnet', + description: 'Claude Sonnet (recommended)', + delegatorName: undefined, + model: 'sonnet', + }, + { + label: 'opus', + description: 'Claude Opus (most capable)', + delegatorName: undefined, + model: 'opus', + }, + { + label: 'haiku', + description: 'Claude Haiku (fastest)', + delegatorName: undefined, + model: 'haiku', + }, + ]; + } + + const items: DelegatorPickItem[] = [ { - label: 'haiku', - description: 'Claude Haiku (fastest)', - model: 'haiku', + label: '$(rocket) Auto', + description: 'Use default delegator', + delegatorName: undefined, + model: 'sonnet', // fallback model if backend resolution fails }, ]; - const modelChoice = await vscode.window.showQuickPick(modelItems, { - title: `Launch ${ticket.id}: Select Model`, - placeHolder: 'Choose the model to use', + for (const d of delegators) { + const yoloFlag = d.launch_config?.yolo ? ' · yolo' : ''; + items.push({ + label: d.display_name || d.name, + description: `${d.llm_tool}:${d.model}${yoloFlag}`, + delegatorName: d.name, + model: d.model as ModelOption, + }); + } + + return items; +} + +/** + * Show launch options dialog + */ +export async function showLaunchOptionsDialog( + ticket: TicketInfo, + hasExistingSession: boolean, + ticketsDir?: string +): Promise { + // Fetch delegators from API + const delegators = await fetchDelegators(ticketsDir); + const delegatorItems = buildDelegatorItems(delegators); + + const delegatorChoice = await vscode.window.showQuickPick(delegatorItems, { + title: `Launch ${ticket.id}: Select Delegator`, + placeHolder: + delegators.length > 0 + ? 'Choose a delegator or use auto' + : 'Choose the model to use', }); - if (!modelChoice) { + if (!delegatorChoice) { return undefined; } @@ -80,7 +148,8 @@ export async function showLaunchOptionsDialog( const selectedLabels = optionChoices.map((c) => c.label); return { - model: modelChoice.model, + delegator: delegatorChoice.delegatorName ?? null, + model: delegatorChoice.model, yoloMode: selectedLabels.includes('YOLO Mode'), resumeSession: selectedLabels.includes('Resume Session'), }; @@ -115,31 +184,28 @@ export async function showTicketPicker( } /** - * Show quick model picker (for fast launches) + * Show quick delegator picker (for fast launches) */ -export async function showQuickModelPicker(): Promise { - const modelItems: ModelPickItem[] = [ - { - label: '$(sparkle) Sonnet', - description: 'Recommended balance of speed and capability', - model: 'sonnet', - }, - { - label: '$(star-full) Opus', - description: 'Most capable, slower', - model: 'opus', - }, - { - label: '$(zap) Haiku', - description: 'Fastest, simpler tasks', - model: 'haiku', - }, - ]; +export async function showQuickDelegatorPicker( + ticketsDir?: string +): Promise | undefined> { + const delegators = await fetchDelegators(ticketsDir); + const items = buildDelegatorItems(delegators); - const choice = await vscode.window.showQuickPick(modelItems, { - title: 'Select Model', - placeHolder: 'Choose model for launch', + const choice = await vscode.window.showQuickPick(items, { + title: 'Select Delegator', + placeHolder: + delegators.length > 0 + ? 'Choose a delegator for launch' + : 'Choose model for launch', }); - return choice?.model; + if (!choice) { + return undefined; + } + + return { + delegator: choice.delegatorName ?? null, + model: choice.model, + }; } diff --git a/vscode-extension/src/launch-manager.ts b/vscode-extension/src/launch-manager.ts index 8366723..8e0e9ae 100644 --- a/vscode-extension/src/launch-manager.ts +++ b/vscode-extension/src/launch-manager.ts @@ -135,8 +135,9 @@ export class LaunchManager { const response: LaunchTicketResponse = await apiClient.launchTicket( ticket.id, { + delegator: options.delegator ?? null, provider: null, - model: options.model, + model: options.delegator ? null : options.model, yolo_mode: options.yoloMode, wrapper: 'vscode', retry_reason: null, @@ -186,12 +187,14 @@ export class LaunchManager { if (choice === 'Launch Fresh') { await this.launchTicket(ticket, { + delegator: null, model: 'sonnet', yoloMode: false, resumeSession: false, }); } else if (choice === 'Resume Session') { await this.launchTicket(ticket, { + delegator: null, model: 'sonnet', yoloMode: false, resumeSession: true, diff --git a/vscode-extension/src/mcp-connect.ts b/vscode-extension/src/mcp-connect.ts index 41b245c..bcbefd8 100644 --- a/vscode-extension/src/mcp-connect.ts +++ b/vscode-extension/src/mcp-connect.ts @@ -2,8 +2,7 @@ * MCP connection logic for Operator VS Code extension. * * Discovers the local Operator API, fetches the MCP descriptor, - * builds a vscode:// deep link, and opens it to register the - * Operator MCP server in VS Code. + * and registers the Operator MCP server in VS Code workspace settings. */ import * as vscode from 'vscode'; @@ -54,34 +53,21 @@ export async function fetchMcpDescriptor( } /** - * Build a VS Code MCP deep link URI from an MCP descriptor. - * - * The deep link format is: - * vscode://modelcontextprotocol.mcp/connect?config= - * - * Where the JSON config contains: - * { name, type: "sse", url: transport_url } + * Check whether an MCP server named "operator" is already registered + * in VS Code workspace settings. */ -export function buildMcpDeepLink( - descriptor: McpDescriptorResponse -): vscode.Uri { - const config = { - name: descriptor.server_name, - type: 'sse', - url: descriptor.transport_url, - }; - - const base64 = Buffer.from(JSON.stringify(config)).toString('base64'); - return vscode.Uri.parse( - `vscode://modelcontextprotocol.mcp/connect?config=${base64}` - ); +export function isMcpServerRegistered(): boolean { + const mcpConfig = vscode.workspace.getConfiguration('mcp'); + const servers = mcpConfig.get>('servers') || {}; + return 'operator' in servers; } /** * Connect Operator as an MCP server in VS Code. * * Discovers the running API, fetches the MCP descriptor, - * builds a deep link, and opens it. + * and writes the server config into VS Code workspace settings + * under the `mcp.servers` key. */ export async function connectMcpServer( ticketsDir: string | undefined @@ -101,15 +87,24 @@ export async function connectMcpServer( return; } - // 3. Build and open the deep link - const uri = buildMcpDeepLink(descriptor); + // 3. Write MCP server config to workspace settings + const mcpConfig = vscode.workspace.getConfiguration('mcp'); + const servers = mcpConfig.get>('servers') || {}; - const opened = await vscode.env.openExternal(uri); - if (!opened) { - void vscode.window.showErrorMessage( - 'Failed to open MCP connection. VS Code may not support MCP deep links in this version.' - ); - } + servers['operator'] = { + type: 'sse', + url: descriptor.transport_url, + }; + + await mcpConfig.update( + 'servers', + servers, + vscode.ConfigurationTarget.Workspace + ); + + void vscode.window.showInformationMessage( + `Operator MCP server registered (${descriptor.transport_url})` + ); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to connect MCP server'; diff --git a/vscode-extension/src/sections/config-section.ts b/vscode-extension/src/sections/config-section.ts new file mode 100644 index 0000000..3a0d082 --- /dev/null +++ b/vscode-extension/src/sections/config-section.ts @@ -0,0 +1,123 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection, ConfigState } from './types'; +import { + resolveWorkingDirectory, + configFileExists, + getResolvedConfigPath, +} from '../config-paths'; + +export class ConfigSection implements StatusSection { + readonly sectionId = 'config'; + + private state: ConfigState = { + workingDirSet: false, + workingDir: '', + configExists: false, + configPath: '', + }; + + isReady(): boolean { + return this.state.workingDirSet && this.state.configExists; + } + + async check(ctx: SectionContext): Promise { + const workingDir = ctx.extensionContext.globalState.get('operator.workingDirectory') + || resolveWorkingDirectory(); + const workingDirSet = !!workingDir; + const configExists = await configFileExists(); + const configPath = getResolvedConfigPath(); + + this.state = { + workingDirSet, + workingDir: workingDir || '', + configExists, + configPath: configPath || '', + }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + const configuredBoth = this.state.workingDirSet && this.state.configExists; + + const configCommand = !configuredBoth + ? this.state.workingDirSet + ? { command: 'operator.runSetup', title: 'Run Operator Setup' } + : { command: 'operator.selectWorkingDirectory', title: 'Select Working Directory' } + : undefined; + + return new StatusItem({ + label: 'Configuration', + description: configuredBoth + ? path.basename(this.state.workingDir) + : 'Setup required', + icon: configuredBoth ? 'check' : 'debug-configure', + collapsibleState: configuredBoth + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.Expanded, + sectionId: this.sectionId, + command: configCommand, + }); + } + + getChildren(ctx: SectionContext, _element?: StatusItem): StatusItem[] { + const items: StatusItem[] = []; + + if (this.state.workingDirSet) { + items.push(new StatusItem({ + label: 'Working Directory', + description: this.state.workingDir, + icon: 'folder-opened', + contextValue: 'workingDirConfigured', + sectionId: this.sectionId, + })); + } else { + items.push(new StatusItem({ + label: 'Working Directory', + description: 'Not set', + icon: 'folder', + command: { + command: 'operator.selectWorkingDirectory', + title: 'Select Working Directory', + }, + sectionId: this.sectionId, + })); + } + + items.push(new StatusItem({ + label: 'Config File', + description: this.state.configExists + ? this.state.configPath + : 'Not found', + icon: this.state.configExists ? 'file' : 'file-add', + command: { + command: 'operator.openSettings', + title: 'Open Settings', + }, + sectionId: this.sectionId, + })); + + if (ctx.ticketsDir) { + items.push(new StatusItem({ + label: 'Tickets', + description: ctx.ticketsDir, + icon: 'markdown', + command: { + command: 'operator.revealTicketsDir', + title: 'Reveal in Explorer', + }, + sectionId: this.sectionId, + })); + } else { + items.push(new StatusItem({ + label: 'Tickets', + description: 'Not found', + icon: 'markdown', + tooltip: 'No .tickets directory found', + sectionId: this.sectionId, + })); + } + + return items; + } +} diff --git a/vscode-extension/src/sections/connections-section.ts b/vscode-extension/src/sections/connections-section.ts new file mode 100644 index 0000000..cf69ff2 --- /dev/null +++ b/vscode-extension/src/sections/connections-section.ts @@ -0,0 +1,288 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection, WebhookStatus, ApiStatus } from './types'; +import { SessionInfo } from '../types'; +import { discoverApiUrl, ApiSessionInfo } from '../api-client'; +import { getOperatorPath, getOperatorVersion } from '../operator-binary'; +import { isMcpServerRegistered } from '../mcp-connect'; + +export class ConnectionsSection implements StatusSection { + readonly sectionId = 'connections'; + + private webhookStatus: WebhookStatus = { running: false }; + private apiStatus: ApiStatus = { connected: false }; + private operatorVersion: string | undefined; + private mcpRegistered: boolean = false; + + get isApiConnected(): boolean { + return this.apiStatus.connected; + } + + isConfigured(): boolean { + return this.apiStatus.connected || this.webhookStatus.running; + } + + async check(ctx: SectionContext): Promise { + await Promise.allSettled([ + this.checkWebhookStatus(ctx), + this.checkApiStatus(ctx), + this.checkOperatorVersion(ctx), + ]); + this.mcpRegistered = isMcpServerRegistered(); + } + + private async checkWebhookStatus(ctx: SectionContext): Promise { + if (!ctx.ticketsDir) { + this.webhookStatus = { running: false }; + return; + } + + const webhookSessionFile = path.join(ctx.ticketsDir, 'operator', 'vscode-session.json'); + try { + const content = await fs.readFile(webhookSessionFile, 'utf-8'); + const session = JSON.parse(content) as SessionInfo; + + this.webhookStatus = { + running: true, + version: session.version, + port: session.port, + workspace: session.workspace, + sessionFile: webhookSessionFile, + }; + } catch { + this.webhookStatus = { running: false }; + } + + // Fall back to live server state if file check missed it + if (!this.webhookStatus.running && ctx.webhookServer?.isRunning()) { + this.webhookStatus = { + running: true, + port: ctx.webhookServer.getPort(), + }; + } + } + + private async checkApiStatus(ctx: SectionContext): Promise { + if (ctx.ticketsDir) { + const apiSessionFile = path.join(ctx.ticketsDir, 'operator', 'api-session.json'); + try { + const content = await fs.readFile(apiSessionFile, 'utf-8'); + const session = JSON.parse(content) as ApiSessionInfo; + const apiUrl = `http://localhost:${session.port}`; + + if (await this.tryHealthCheck(apiUrl, session.version)) { + return; + } + } catch { + // Fall through + } + } + + const apiUrl = await discoverApiUrl(ctx.ticketsDir); + await this.tryHealthCheck(apiUrl); + } + + private async checkOperatorVersion(ctx: SectionContext): Promise { + const operatorPath = await getOperatorPath(ctx.extensionContext); + if (operatorPath) { + this.operatorVersion = await getOperatorVersion(operatorPath) || undefined; + return; + } + + try { + const response = await fetch('https://operator.untra.io/VERSION'); + if (response.ok) { + this.operatorVersion = (await response.text()).trim() || undefined; + } + } catch { + this.operatorVersion = undefined; + } + } + + private async tryHealthCheck(apiUrl: string, sessionVersion?: string): Promise { + try { + const response = await fetch(`${apiUrl}/api/v1/health`); + if (response.ok) { + const health = await response.json() as { version?: string }; + const port = new URL(apiUrl).port; + this.apiStatus = { + connected: true, + version: health.version || sessionVersion, + port: port ? parseInt(port, 10) : 7008, + url: apiUrl, + }; + return true; + } + } catch { + // Health check failed + } + this.apiStatus = { connected: false }; + return false; + } + + getTopLevelItem(ctx: SectionContext): StatusItem { + return new StatusItem({ + label: 'Connections', + description: ctx.configReady ? this.getConnectionsSummary() : 'Not Ready', + icon: ctx.configReady ? this.getConnectionsIcon() : 'debug-configure', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + sectionId: this.sectionId, + command: ctx.configReady ? undefined : ( + ctx.extensionContext.globalState.get('operator.workingDirectory') + ? { command: 'operator.runSetup', title: 'Run Operator Setup' } + : { command: 'operator.selectWorkingDirectory', title: 'Select Working Directory' } + ), + }); + } + + getChildren(ctx: SectionContext, _element?: StatusItem): StatusItem[] { + const configuredBoth = ctx.configReady; + + // 1. Session Wrapper + const wrapperItem = new StatusItem({ + label: 'Session Wrapper', + description: 'VS Code Terminal', + icon: 'operator-vscode', + tooltip: 'Sessions route through the VS Code webhook to managed terminals', + sectionId: this.sectionId, + }); + + // 2. API Version + let versionItem: StatusItem; + if (this.apiStatus.connected && this.apiStatus.version) { + const swaggerUrl = `http://localhost:${this.apiStatus.port || 7008}/swagger-ui`; + versionItem = new StatusItem({ + label: 'Operator', + description: 'Version ' + this.apiStatus.version, + icon: 'versions', + tooltip: 'Open Swagger UI', + command: { + command: 'vscode.open', + title: 'Open Swagger UI', + arguments: [vscode.Uri.parse(swaggerUrl)], + }, + sectionId: this.sectionId, + }); + } else { + versionItem = new StatusItem({ + label: 'Operator Version', + description: this.operatorVersion ? 'Version ' + this.operatorVersion : 'Not installed', + icon: 'versions', + tooltip: this.operatorVersion + ? `Installed: ${this.operatorVersion} — click to update` + : 'Click to download Operator', + command: { + command: 'operator.downloadOperator', + title: 'Download Operator', + }, + sectionId: this.sectionId, + }); + } + + // 3. API Connection + const apiItem = this.apiStatus.connected + ? new StatusItem({ + label: 'API', + description: this.apiStatus.url || 'Connected', + icon: 'pass', + tooltip: `Operator REST API at ${this.apiStatus.url}`, + sectionId: this.sectionId, + }) + : new StatusItem({ + label: 'API', + description: configuredBoth ? 'Disconnected' : 'Not Ready', + icon: 'error', + tooltip: configuredBoth + ? 'Click to start Operator API server' + : 'Complete configuration first', + command: configuredBoth ? { + command: 'operator.startOperatorServer', + title: 'Start Operator Server', + } : undefined, + sectionId: this.sectionId, + }); + + // 4. Webhook Connection + const webhookItem = this.webhookStatus.running + ? new StatusItem({ + label: 'Webhook', + description: `Running${this.webhookStatus.port ? ` :${this.webhookStatus.port}` : ''}`, + icon: 'operator-webhook', + tooltip: `Webhook bridge: Operator API \u2192 VS Code terminals (port ${this.webhookStatus.port})`, + sectionId: this.sectionId, + }) + : new StatusItem({ + label: 'Webhook', + description: configuredBoth ? `Stopped` : 'Not Ready', + icon: 'operator-webhook', + tooltip: configuredBoth + ? 'Click to start webhook server' + : 'Complete configuration first', + command: configuredBoth ? { + command: 'operator.startWebhookServer', + title: 'Start Webhook Server', + } : undefined, + sectionId: this.sectionId, + }); + + // 5. MCP Connection + let mcpItem: StatusItem; + if (this.mcpRegistered) { + mcpItem = new StatusItem({ + label: 'MCP', + description: 'Connected', + icon: 'pass', + tooltip: 'Operator MCP server is registered in workspace settings', + command: this.apiStatus.connected ? { + command: 'operator.connectMcpServer', + title: 'Reconnect MCP Server', + } : undefined, + sectionId: this.sectionId, + }); + } else if (this.apiStatus.connected) { + mcpItem = new StatusItem({ + label: 'MCP', + description: 'Connect', + icon: 'plug', + tooltip: 'Connect Operator as MCP server in VS Code', + command: { + command: 'operator.connectMcpServer', + title: 'Connect MCP Server', + }, + sectionId: this.sectionId, + }); + } else { + mcpItem = new StatusItem({ + label: 'MCP', + description: 'API required', + icon: 'circle-slash', + tooltip: 'Start the Operator API to enable MCP connection', + sectionId: this.sectionId, + }); + } + + return [wrapperItem, versionItem, apiItem, webhookItem, mcpItem]; + } + + private getConnectionsSummary(): string { + if (this.apiStatus.connected && this.webhookStatus.running) { + return 'All connected'; + } + if (this.apiStatus.connected || this.webhookStatus.running) { + return 'Partial'; + } + return 'Disconnected'; + } + + private getConnectionsIcon(): string { + if (this.apiStatus.connected && this.webhookStatus.running) { + return 'pass'; + } + if (this.apiStatus.connected || this.webhookStatus.running) { + return 'warning'; + } + return 'error'; + } +} diff --git a/vscode-extension/src/sections/delegator-section.ts b/vscode-extension/src/sections/delegator-section.ts new file mode 100644 index 0000000..5a5a362 --- /dev/null +++ b/vscode-extension/src/sections/delegator-section.ts @@ -0,0 +1,100 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection } from './types'; +import { discoverApiUrl } from '../api-client'; +import type { DelegatorResponse } from '../generated/DelegatorResponse'; +import type { DelegatorsResponse } from '../generated/DelegatorsResponse'; + +interface DelegatorState { + apiAvailable: boolean; + delegators: DelegatorResponse[]; +} + +export class DelegatorSection implements StatusSection { + readonly sectionId = 'delegators'; + + private state: DelegatorState = { apiAvailable: false, delegators: [] }; + + async check(ctx: SectionContext): Promise { + try { + const apiUrl = await discoverApiUrl(ctx.ticketsDir); + const response = await fetch(`${apiUrl}/api/v1/delegators`); + if (response.ok) { + const data = await response.json() as DelegatorsResponse; + this.state = { apiAvailable: true, delegators: data.delegators }; + return; + } + } catch { + // API not available + } + this.state = { apiAvailable: false, delegators: [] }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + if (this.state.apiAvailable) { + const count = this.state.delegators.length; + return new StatusItem({ + label: 'Delegators', + description: count > 0 + ? `${count} delegator${count !== 1 ? 's' : ''}` + : 'None configured', + icon: 'rocket', + collapsibleState: count > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.Expanded, + sectionId: this.sectionId, + }); + } + + return new StatusItem({ + label: 'Delegators', + description: 'API required', + icon: 'rocket', + collapsibleState: vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + }); + } + + getChildren(_ctx: SectionContext, _element?: StatusItem): StatusItem[] { + const items: StatusItem[] = []; + + if (!this.state.apiAvailable) { + return items; + } + + for (const delegator of this.state.delegators) { + const label = delegator.display_name || delegator.name; + const yoloFlag = delegator.launch_config?.yolo ? ' · yolo' : ''; + + items.push(new StatusItem({ + label, + description: `${delegator.llm_tool}:${delegator.model}${yoloFlag}`, + icon: `operator-${delegator.llm_tool}`, + tooltip: this.buildTooltip(delegator), + sectionId: this.sectionId, + })); + } + + items.push(new StatusItem({ + label: 'Add Delegator', + icon: 'add', + command: { + command: 'operator.openSettings', + title: 'Add Delegator', + }, + sectionId: this.sectionId, + })); + + return items; + } + + private buildTooltip(d: DelegatorResponse): string { + const lines = [`${d.name}: ${d.llm_tool} / ${d.model}`]; + if (d.launch_config) { + if (d.launch_config.yolo) { lines.push('YOLO mode: enabled'); } + if (d.launch_config.permission_mode) { lines.push(`Permission: ${d.launch_config.permission_mode}`); } + if (d.launch_config.flags.length > 0) { lines.push(`Flags: ${d.launch_config.flags.join(' ')}`); } + } + return lines.join('\n'); + } +} diff --git a/vscode-extension/src/sections/git-section.ts b/vscode-extension/src/sections/git-section.ts new file mode 100644 index 0000000..b52013b --- /dev/null +++ b/vscode-extension/src/sections/git-section.ts @@ -0,0 +1,150 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection, GitState } from './types'; + +/** Map provider names to branded ThemeIcon IDs */ +const PROVIDER_ICONS: Record = { + github: 'operator-github', + gitlab: 'operator-gitlab', + bitbucket: 'repo', + azuredevops: 'azure-devops', +}; + +export class GitSection implements StatusSection { + readonly sectionId = 'git'; + + private state: GitState = { configured: false }; + + isConfigured(): boolean { + return this.state.configured; + } + + async check(ctx: SectionContext): Promise { + const config = await ctx.readConfigToml(); + const gitSection = config.git as Record | undefined; + + if (!gitSection) { + this.state = { configured: false }; + return; + } + + const provider = gitSection.provider as string | undefined; + const github = gitSection.github as Record | undefined; + const gitlab = gitSection.gitlab as Record | undefined; + const githubEnabled = github?.enabled as boolean | undefined; + const gitlabEnabled = gitlab?.enabled as boolean | undefined; + const branchFormat = gitSection.branch_format as string | undefined; + const useWorktrees = gitSection.use_worktrees as boolean | undefined; + + // Determine token status based on active provider + let tokenSet = false; + if (provider === 'gitlab' || gitlabEnabled) { + const tokenEnv = (gitlab?.token_env as string) || 'GITLAB_TOKEN'; + tokenSet = !!process.env[tokenEnv]; + } else { + const tokenEnv = (github?.token_env as string) || 'GITHUB_TOKEN'; + tokenSet = !!process.env[tokenEnv]; + } + + const configured = !!(provider || githubEnabled || gitlabEnabled); + + this.state = { + configured, + provider, + githubEnabled, + tokenSet, + branchFormat, + useWorktrees, + }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + const providerLabel = this.state.provider + ? this.state.provider.charAt(0).toUpperCase() + this.state.provider.slice(1) + : 'GitHub'; + + return new StatusItem({ + label: 'Git', + description: this.state.configured ? providerLabel : 'Not configured', + icon: this.state.configured ? 'check' : 'warning', + collapsibleState: this.state.configured + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.Expanded, + sectionId: this.sectionId, + command: this.state.configured ? undefined : { + command: 'operator.startGitOnboarding', + title: 'Connect Git Provider', + }, + }); + } + + getChildren(_ctx: SectionContext, _element?: StatusItem): StatusItem[] { + const items: StatusItem[] = []; + + if (this.state.configured) { + // Provider with branded icon + const providerName = this.state.provider || 'github'; + const providerIcon = PROVIDER_ICONS[providerName] || 'source-control'; + const providerLabel = providerName.charAt(0).toUpperCase() + providerName.slice(1); + items.push(new StatusItem({ + label: 'Provider', + description: providerLabel, + icon: providerIcon, + sectionId: this.sectionId, + })); + + // Token status — clickable when not set + const tokenLabel = providerName === 'gitlab' ? 'GitLab Token' : 'GitHub Token'; + items.push(new StatusItem({ + label: tokenLabel, + description: this.state.tokenSet ? 'Set' : 'Not set', + icon: this.state.tokenSet ? 'key' : 'warning', + sectionId: this.sectionId, + command: this.state.tokenSet ? undefined : { + command: providerName === 'gitlab' ? 'operator.configureGitLab' : 'operator.configureGitHub', + title: 'Set Token', + }, + })); + + // Branch Format + if (this.state.branchFormat) { + items.push(new StatusItem({ + label: 'Branch Format', + description: this.state.branchFormat, + icon: 'git-branch', + sectionId: this.sectionId, + })); + } + + // Worktrees + items.push(new StatusItem({ + label: 'Worktrees', + description: this.state.useWorktrees ? 'Enabled' : 'Disabled', + icon: 'git-merge', + sectionId: this.sectionId, + })); + } else { + // Unconfigured: show provider options + items.push(new StatusItem({ + label: 'GitHub', + icon: 'operator-github', + command: { + command: 'operator.configureGitHub', + title: 'Connect GitHub', + }, + sectionId: this.sectionId, + })); + items.push(new StatusItem({ + label: 'GitLab', + icon: 'operator-gitlab', + command: { + command: 'operator.configureGitLab', + title: 'Connect GitLab', + }, + sectionId: this.sectionId, + })); + } + + return items; + } +} diff --git a/vscode-extension/src/sections/index.ts b/vscode-extension/src/sections/index.ts new file mode 100644 index 0000000..2d5b18e --- /dev/null +++ b/vscode-extension/src/sections/index.ts @@ -0,0 +1,21 @@ +export type { + SectionContext, + StatusSection, + WebhookStatus, + ApiStatus, + ConfigState, + KanbanProviderState, + KanbanState, + LlmState, + LlmToolInfo, + GitState, +} from './types'; + +export { ConfigSection } from './config-section'; +export { ConnectionsSection } from './connections-section'; +export { KanbanSection } from './kanban-section'; +export { LlmSection } from './llm-section'; +export { GitSection } from './git-section'; +export { IssueTypeSection } from './issuetype-section'; +export { DelegatorSection } from './delegator-section'; +export { ManagedProjectsSection } from './managed-projects-section'; diff --git a/vscode-extension/src/sections/issuetype-section.ts b/vscode-extension/src/sections/issuetype-section.ts new file mode 100644 index 0000000..732952d --- /dev/null +++ b/vscode-extension/src/sections/issuetype-section.ts @@ -0,0 +1,94 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection } from './types'; +import type { IssueTypeSummary } from '../generated/IssueTypeSummary'; +import { DEFAULT_ISSUE_TYPES, GLYPH_TO_ICON, COLOR_TO_THEME } from '../issuetype-service'; +import { discoverApiUrl } from '../api-client'; + +interface IssueTypeState { + apiAvailable: boolean; + types: IssueTypeSummary[]; +} + +export class IssueTypeSection implements StatusSection { + readonly sectionId = 'issuetypes'; + + private state: IssueTypeState = { apiAvailable: false, types: [] }; + + async check(ctx: SectionContext): Promise { + // Try fetching from API + try { + const apiUrl = await discoverApiUrl(ctx.ticketsDir); + const response = await fetch(`${apiUrl}/api/v1/issuetypes`); + if (response.ok) { + const types = await response.json() as IssueTypeSummary[]; + this.state = { apiAvailable: true, types }; + return; + } + } catch { + // API not available + } + + // Fall back to defaults + this.state = { apiAvailable: false, types: [...DEFAULT_ISSUE_TYPES] }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + const count = this.state.types.length; + if (this.state.apiAvailable) { + return new StatusItem({ + label: 'Issue Types', + description: `${count} type${count !== 1 ? 's' : ''}`, + icon: 'check', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + sectionId: this.sectionId, + }); + } + + return new StatusItem({ + label: 'Issue Types', + description: `${count} defaults (API offline)`, + icon: 'warning', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + sectionId: this.sectionId, + }); + } + + getChildren(_ctx: SectionContext, _element?: StatusItem): StatusItem[] { + const items: StatusItem[] = []; + + for (const type of this.state.types) { + const iconName = GLYPH_TO_ICON[type.glyph] ?? 'file'; + const themeColorId = type.color ? COLOR_TO_THEME[type.color] : undefined; + const modeLabel = type.mode === 'autonomous' ? 'autonomous' : 'paired'; + + items.push(new StatusItem({ + label: type.key, + description: `${type.name} · ${modeLabel}`, + icon: iconName, + tooltip: `${type.description}\nSource: ${type.source} · ${type.stepCount} steps`, + sectionId: this.sectionId, + })); + + // Apply color to the icon if available + const item = items[items.length - 1]!; + if (themeColorId) { + item.iconPath = new vscode.ThemeIcon(iconName, new vscode.ThemeColor(themeColorId)); + } + } + + if (this.state.apiAvailable) { + items.push(new StatusItem({ + label: 'Manage Issue Types', + icon: 'gear', + command: { + command: 'operator.openSettings', + title: 'Open Settings', + }, + sectionId: this.sectionId, + })); + } + + return items; + } +} diff --git a/vscode-extension/src/sections/kanban-section.ts b/vscode-extension/src/sections/kanban-section.ts new file mode 100644 index 0000000..276518b --- /dev/null +++ b/vscode-extension/src/sections/kanban-section.ts @@ -0,0 +1,233 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection, KanbanState, KanbanProviderState } from './types'; +import { getKanbanWorkspaces } from '../walkthrough'; + +export class KanbanSection implements StatusSection { + readonly sectionId = 'kanban'; + + private state: KanbanState = { configured: false, providers: [] }; + + isConfigured(): boolean { + return this.state.configured; + } + + async check(ctx: SectionContext): Promise { + const config = await ctx.readConfigToml(); + const kanbanSection = config.kanban as Record | undefined; + const providers: KanbanProviderState[] = []; + + if (kanbanSection) { + // Parse Jira providers from config.toml + const jiraSection = kanbanSection.jira as Record | undefined; + if (jiraSection) { + for (const [domain, wsConfig] of Object.entries(jiraSection)) { + const ws = wsConfig as Record; + if (ws.enabled === false) { continue; } + const projects: KanbanProviderState['projects'] = []; + const projectsSection = ws.projects as Record | undefined; + if (projectsSection) { + for (const [projectKey, projConfig] of Object.entries(projectsSection)) { + const proj = projConfig as Record; + projects.push({ + key: projectKey, + collectionName: (proj.collection_name as string) || 'dev_kanban', + url: `https://${domain}/browse/${projectKey}`, + }); + } + } + providers.push({ + provider: 'jira', + key: domain, + enabled: ws.enabled !== false, + displayName: domain, + url: `https://${domain}`, + projects, + }); + } + } + + // Parse Linear providers from config.toml + const linearSection = kanbanSection.linear as Record | undefined; + if (linearSection) { + for (const [teamId, wsConfig] of Object.entries(linearSection)) { + const ws = wsConfig as Record; + if (ws.enabled === false) { continue; } + const projects: KanbanProviderState['projects'] = []; + const projectsSection = ws.projects as Record | undefined; + if (projectsSection) { + for (const [projectKey, projConfig] of Object.entries(projectsSection)) { + const proj = projConfig as Record; + projects.push({ + key: projectKey, + collectionName: (proj.collection_name as string) || 'dev_kanban', + url: `https://linear.app/team/${projectKey}`, + }); + } + } + providers.push({ + provider: 'linear', + key: teamId, + enabled: ws.enabled !== false, + displayName: teamId, + url: 'https://linear.app', + projects, + }); + } + } + } + + // Fall back to env-var-based detection if config.toml has no kanban section + if (providers.length === 0) { + const workspaces = await getKanbanWorkspaces(); + for (const ws of workspaces) { + providers.push({ + provider: ws.provider, + key: ws.name, + enabled: ws.configured, + displayName: ws.name, + url: ws.url, + projects: [], + }); + } + } + + this.state = { + configured: providers.length > 0, + providers, + }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + return new StatusItem({ + label: 'Kanban', + description: this.state.configured + ? this.getKanbanSummary() + : 'No provider connected', + icon: this.state.configured ? 'check' : 'warning', + collapsibleState: this.state.configured + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.Expanded, + sectionId: this.sectionId, + command: this.state.configured ? undefined : { + command: 'operator.startKanbanOnboarding', + title: 'Configure Kanban', + }, + }); + } + + getChildren(_ctx: SectionContext, element?: StatusItem): StatusItem[] { + // Workspace-level expansion: show project children + if (element && element.provider && element.workspaceKey && !element.projectKey) { + return this.getKanbanProjectChildren(element.provider, element.workspaceKey); + } + + // Top-level kanban children + const items: StatusItem[] = []; + + if (this.state.configured) { + for (const prov of this.state.providers) { + const providerLabel = prov.provider === 'jira' ? 'Jira' : 'Linear'; + const providerIcon = prov.provider === 'jira' ? 'operator-atlassian' : 'operator-linear'; + items.push(new StatusItem({ + label: providerLabel, + description: prov.displayName, + icon: providerIcon, + tooltip: prov.url, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + command: { + command: 'vscode.open', + title: 'Open in Browser', + arguments: [vscode.Uri.parse(prov.url)], + }, + contextValue: 'kanbanWorkspace', + provider: prov.provider, + workspaceKey: prov.key, + sectionId: this.sectionId, + })); + } + + items.push(new StatusItem({ + label: 'Add Provider', + icon: 'add', + command: { + command: 'operator.startKanbanOnboarding', + title: 'Add Kanban Provider', + }, + sectionId: this.sectionId, + })); + } else { + items.push(new StatusItem({ + label: 'Configure Jira', + icon: 'operator-atlassian', + command: { + command: 'operator.configureJira', + title: 'Configure Jira', + }, + sectionId: this.sectionId, + })); + items.push(new StatusItem({ + label: 'Configure Linear', + icon: 'operator-linear', + command: { + command: 'operator.configureLinear', + title: 'Configure Linear', + }, + sectionId: this.sectionId, + })); + } + + return items; + } + + private getKanbanProjectChildren(provider: string, workspaceKey: string): StatusItem[] { + const items: StatusItem[] = []; + const prov = this.state.providers.find( + (p) => p.provider === provider && p.key === workspaceKey + ); + if (!prov) { return items; } + + for (const proj of prov.projects) { + items.push(new StatusItem({ + label: proj.key, + description: proj.collectionName, + icon: 'project', + tooltip: proj.url, + command: { + command: 'vscode.open', + title: 'Open in Browser', + arguments: [vscode.Uri.parse(proj.url)], + }, + contextValue: 'kanbanSyncConfig', + provider: prov.provider, + workspaceKey: prov.key, + projectKey: proj.key, + sectionId: this.sectionId, + })); + } + + const addLabel = provider === 'jira' ? 'Add Jira Project' : 'Add Linear Workspace'; + const addCommand = provider === 'jira' ? 'operator.addJiraProject' : 'operator.addLinearTeam'; + items.push(new StatusItem({ + label: addLabel, + icon: 'add', + command: { + command: addCommand, + title: addLabel, + arguments: [workspaceKey], + }, + sectionId: this.sectionId, + })); + + return items; + } + + private getKanbanSummary(): string { + const prov = this.state.providers[0]; + if (!prov) { + return ''; + } + const provider = prov.provider === 'jira' ? 'Jira' : 'Linear'; + return `${provider}: ${prov.displayName}`; + } +} diff --git a/vscode-extension/src/sections/llm-section.ts b/vscode-extension/src/sections/llm-section.ts new file mode 100644 index 0000000..62b07d6 --- /dev/null +++ b/vscode-extension/src/sections/llm-section.ts @@ -0,0 +1,192 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection, LlmState, LlmToolInfo } from './types'; +import { detectInstalledLlmTools } from '../walkthrough'; +import { discoverApiUrl } from '../api-client'; +import type { DetectedTool } from '../generated/DetectedTool'; + +export class LlmSection implements StatusSection { + readonly sectionId = 'llm'; + + private state: LlmState = { detected: false, tools: [], configDetected: [], toolDetails: [] }; + + isConfigured(): boolean { + return this.state.detected; + } + + async check(ctx: SectionContext): Promise { + const toolDetails: LlmToolInfo[] = []; + const seen = new Set(); + + // Priority 1: Try API (has model_aliases from embedded tool configs) + try { + const apiUrl = await discoverApiUrl(ctx.ticketsDir); + const response = await fetch(`${apiUrl}/api/v1/llm-tools`); + if (response.ok) { + const data = await response.json() as { tools: DetectedTool[] }; + for (const tool of data.tools) { + seen.add(tool.name); + toolDetails.push({ + name: tool.name, + version: tool.version, + models: tool.model_aliases, + }); + } + } + } catch { + // API not available + } + + // Priority 2: Config TOML llm_tools.detected (may have model_aliases) + if (toolDetails.length === 0) { + const config = await ctx.readConfigToml(); + const llmTools = config.llm_tools as Record | undefined; + const detectedArray = Array.isArray(llmTools?.detected) ? llmTools.detected as Array> : []; + for (const entry of detectedArray) { + if (typeof entry === 'object' && entry !== null && typeof entry.name === 'string') { + const name = entry.name; + if (seen.has(name)) { continue; } + seen.add(name); + const models = Array.isArray(entry.model_aliases) ? entry.model_aliases as string[] : []; + const version = typeof entry.version === 'string' ? entry.version : undefined; + toolDetails.push({ name, version, models }); + } + } + } + + // Priority 3: PATH detection (no model info — tools won't be expandable) + const tools = await detectInstalledLlmTools(); + for (const tool of tools) { + if (!seen.has(tool.name)) { + seen.add(tool.name); + toolDetails.push({ + name: tool.name, + version: tool.version !== 'unknown' ? tool.version : undefined, + models: [], + }); + } + } + + // Build legacy configDetected for backward compat + const config = await ctx.readConfigToml(); + const llmTools = config.llm_tools as Record | undefined; + const configDetected = Array.isArray(llmTools?.detected) + ? (llmTools.detected as Array).map( + (entry) => { + if (typeof entry === 'string') { + return { name: entry }; + } + return { name: entry.name, version: entry.version }; + } + ) + : []; + + this.state = { + detected: toolDetails.length > 0, + tools, + configDetected, + toolDetails, + }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + return new StatusItem({ + label: 'LLM Tools', + description: this.state.detected + ? this.getLlmSummary() + : 'No tools detected', + icon: this.state.detected ? 'check' : 'warning', + collapsibleState: this.state.detected + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.Expanded, + sectionId: this.sectionId, + command: this.state.detected ? undefined : { + command: 'operator.detectLlmTools', + title: 'Detect LLM Tools', + }, + }); + } + + getChildren(_ctx: SectionContext, element?: StatusItem): StatusItem[] { + // Expanding a tool item: show model aliases + if (element?.contextValue?.startsWith('llmTool:')) { + const toolName = element.contextValue.slice('llmTool:'.length); + return this.getModelChildren(toolName); + } + + const items: StatusItem[] = []; + + if (this.state.detected) { + for (const tool of this.state.toolDetails) { + const hasModels = tool.models.length > 0; + items.push(new StatusItem({ + label: tool.name, + description: tool.version, + icon: `operator-${tool.name}`, + collapsibleState: hasModels + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + contextValue: `llmTool:${tool.name}`, + sectionId: this.sectionId, + })); + } + + items.push(new StatusItem({ + label: 'Detect Tools', + icon: 'search', + command: { + command: 'operator.detectLlmTools', + title: 'Detect LLM Tools', + }, + sectionId: this.sectionId, + })); + } else { + items.push(new StatusItem({ + label: 'Detect Tools', + icon: 'search', + command: { + command: 'operator.detectLlmTools', + title: 'Detect LLM Tools', + }, + sectionId: this.sectionId, + })); + items.push(new StatusItem({ + label: 'Install Claude Code', + icon: 'link-external', + command: { + command: 'vscode.open', + title: 'Install Claude Code', + arguments: [vscode.Uri.parse('https://docs.anthropic.com/en/docs/claude-code')], + }, + sectionId: this.sectionId, + })); + } + + return items; + } + + private getModelChildren(toolName: string): StatusItem[] { + const tool = this.state.toolDetails.find(t => t.name === toolName); + if (!tool) { return []; } + + return tool.models.map(model => new StatusItem({ + label: model, + icon: 'symbol-field', + tooltip: `Create delegator for ${toolName}:${model}`, + command: { + command: 'operator.openCreateDelegator', + title: 'Create Delegator', + arguments: [toolName, model], + }, + sectionId: this.sectionId, + })); + } + + private getLlmSummary(): string { + const count = this.state.toolDetails.length; + if (count === 0) { return ''; } + const first = this.state.toolDetails[0]!; + const label = first.version ? `${first.name} v${first.version}` : first.name; + return count > 1 ? `${label} +${count - 1}` : label; + } +} diff --git a/vscode-extension/src/sections/managed-projects-section.ts b/vscode-extension/src/sections/managed-projects-section.ts new file mode 100644 index 0000000..f9f0568 --- /dev/null +++ b/vscode-extension/src/sections/managed-projects-section.ts @@ -0,0 +1,79 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection } from './types'; +import { discoverApiUrl } from '../api-client'; +import type { ProjectSummary } from '../generated/ProjectSummary'; + +interface ManagedProjectsState { + configured: boolean; + projects: ProjectSummary[]; +} + +export class ManagedProjectsSection implements StatusSection { + readonly sectionId = 'projects'; + + private state: ManagedProjectsState = { configured: false, projects: [] }; + + async check(ctx: SectionContext): Promise { + try { + const apiUrl = await discoverApiUrl(ctx.ticketsDir); + const response = await fetch(`${apiUrl}/api/v1/projects`); + if (response.ok) { + const projects = await response.json() as ProjectSummary[]; + this.state = { configured: true, projects }; + return; + } + } catch { + // API not available + } + this.state = { configured: false, projects: [] }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + if (this.state.configured) { + const count = this.state.projects.length; + return new StatusItem({ + label: 'Managed Projects', + description: `${count} project${count !== 1 ? 's' : ''}`, + icon: 'project', + collapsibleState: count > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + }); + } + + return new StatusItem({ + label: 'Managed Projects', + description: 'API required', + icon: 'project', + collapsibleState: vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + }); + } + + getChildren(_ctx: SectionContext, _element?: StatusItem): StatusItem[] { + if (!this.state.configured) { + return []; + } + + return this.state.projects.map((proj) => { + const details: string[] = []; + if (proj.kind) { details.push(proj.kind); } + if (proj.languages.length > 0) { details.push(proj.languages.join(', ')); } + + return new StatusItem({ + label: proj.project_name, + description: details.join(' · ') || undefined, + icon: proj.exists ? 'folder' : 'folder-library', + tooltip: proj.project_path, + command: proj.exists ? { + command: 'vscode.openFolder', + title: 'Open Project', + arguments: [vscode.Uri.file(proj.project_path), { forceNewWindow: false }], + } : undefined, + sectionId: this.sectionId, + }); + }); + } +} diff --git a/vscode-extension/src/sections/types.ts b/vscode-extension/src/sections/types.ts new file mode 100644 index 0000000..e46d2a5 --- /dev/null +++ b/vscode-extension/src/sections/types.ts @@ -0,0 +1,107 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { DetectedToolResult } from '../walkthrough'; + +/** Shared context provided by the orchestrator to all sections */ +export interface SectionContext { + extensionContext: vscode.ExtensionContext; + ticketsDir: string | undefined; + readConfigToml: () => Promise>; + /** True when working dir is set AND config.toml exists. Set after checks complete. */ + configReady: boolean; + /** True when API or webhook is connected. Set after checks complete. */ + connectionsReady: boolean; + /** True when any kanban provider is configured. Set after checks complete. */ + kanbanConfigured: boolean; + /** True when any LLM tool is detected. Set after checks complete. */ + llmConfigured: boolean; + /** True when git section is configured. Set after checks complete. */ + gitConfigured: boolean; + /** Live webhook server state (provided by extension.ts) */ + webhookServer?: { + isRunning: () => boolean; + getPort: () => number; + }; +} + +/** Every status tree section implements this interface */ +export interface StatusSection { + readonly sectionId: string; + check(ctx: SectionContext): Promise; + getTopLevelItem(ctx: SectionContext): StatusItem; + getChildren(ctx: SectionContext, element?: StatusItem): StatusItem[]; +} + +/** + * Webhook server connection status + */ +export interface WebhookStatus { + running: boolean; + version?: string; + port?: number; + workspace?: string; + sessionFile?: string; +} + +/** + * Operator REST API connection status + */ +export interface ApiStatus { + connected: boolean; + version?: string; + port?: number; + url?: string; +} + +/** Internal state for the Configuration section */ +export interface ConfigState { + workingDirSet: boolean; + workingDir: string; + configExists: boolean; + configPath: string; +} + +/** Config-driven state for a single kanban provider */ +export interface KanbanProviderState { + provider: 'jira' | 'linear'; + key: string; + enabled: boolean; + displayName: string; + url: string; + projects: Array<{ + key: string; + collectionName: string; + url: string; + }>; +} + +/** Internal state for the Kanban section */ +export interface KanbanState { + configured: boolean; + providers: KanbanProviderState[]; +} + +/** Per-tool info with model aliases */ +export interface LlmToolInfo { + name: string; + version?: string; + models: string[]; +} + +/** Internal state for the LLM Tools section */ +export interface LlmState { + detected: boolean; + tools: DetectedToolResult[]; + configDetected: Array<{ name: string; version?: string }>; + toolDetails: LlmToolInfo[]; +} + +/** Internal state for the Git section */ +export interface GitState { + configured: boolean; + provider?: string; + githubEnabled?: boolean; + tokenSet?: boolean; + branchFormat?: string; + useWorktrees?: boolean; +} diff --git a/vscode-extension/src/status-item.ts b/vscode-extension/src/status-item.ts new file mode 100644 index 0000000..40bbf4c --- /dev/null +++ b/vscode-extension/src/status-item.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode'; + +/** + * StatusItem options + */ +export interface StatusItemOptions { + label: string; + description?: string; + icon: string; + tooltip?: string; + collapsibleState?: vscode.TreeItemCollapsibleState; + command?: vscode.Command; + sectionId?: string; + contextValue?: string; // for view/item/context when clause + provider?: string; // 'jira' | 'linear' + workspaceKey?: string; // domain or teamId (config key) + projectKey?: string; // project/team sync config key +} + +/** + * TreeItem for status display + */ +export class StatusItem extends vscode.TreeItem { + public readonly sectionId?: string; + public readonly provider?: string; + public readonly workspaceKey?: string; + public readonly projectKey?: string; + + constructor(opts: StatusItemOptions) { + super( + opts.label, + opts.collapsibleState ?? vscode.TreeItemCollapsibleState.None + ); + this.sectionId = opts.sectionId; + this.provider = opts.provider; + this.workspaceKey = opts.workspaceKey; + this.projectKey = opts.projectKey; + if (opts.description !== undefined) { + this.description = opts.description; + } + this.tooltip = opts.tooltip || (opts.description + ? `${opts.label}: ${opts.description}` + : opts.label); + this.iconPath = new vscode.ThemeIcon(opts.icon); + if (opts.command) { + this.command = opts.command; + } + if (opts.contextValue) { + this.contextValue = opts.contextValue; + } + } +} diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index 042cdbc..c4d6f37 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -1,105 +1,40 @@ /** * Status TreeDataProvider for Operator VS Code extension * - * Displays a hierarchical status tree with 5 collapsible sections: - * 1. Configuration — working directory + config.toml - * 2. Kanban — connected providers and workspaces - * 3. LLM Tools — detected CLI tools - * 4. Git — provider, token, branch format - * 5. Connections — API + Webhook status + * Slim orchestrator that delegates to per-section modules in ./sections/. + * Each section owns its state, check logic, and tree item rendering. * - * Unconfigured sections expand automatically with nudge items - * that link to the relevant setup command. + * Sections use progressive disclosure — they only appear when prerequisites are met: + * Tier 0: Configuration (always visible) + * Tier 1: Connections (requires configReady) + * Tier 2: Kanban, LLM Tools, Git (requires connectionsReady) + * Tier 3: Issue Types (kanbanConfigured), Delegators (llmConfigured), Managed Projects (gitConfigured) */ import * as vscode from 'vscode'; -import * as path from 'path'; import * as fs from 'fs/promises'; -import { SessionInfo } from './types'; -import { discoverApiUrl, ApiSessionInfo } from './api-client'; -import { - resolveWorkingDirectory, - configFileExists, - getResolvedConfigPath, -} from './config-paths'; -import { - detectInstalledLlmTools, - getKanbanWorkspaces, - DetectedToolResult, -} from './walkthrough'; -import { getOperatorPath, getOperatorVersion } from './operator-binary'; +import { getResolvedConfigPath } from './config-paths'; +import { StatusItem } from './status-item'; +import type { SectionContext, StatusSection } from './sections/types'; +import { ConfigSection } from './sections/config-section'; +import { ConnectionsSection } from './sections/connections-section'; +import { KanbanSection } from './sections/kanban-section'; +import { LlmSection } from './sections/llm-section'; +import { GitSection } from './sections/git-section'; +import { IssueTypeSection } from './sections/issuetype-section'; +import { DelegatorSection } from './sections/delegator-section'; +import { ManagedProjectsSection } from './sections/managed-projects-section'; + +// Backward-compatible re-exports +export { StatusItem } from './status-item'; +export type { StatusItemOptions } from './status-item'; +export type { WebhookStatus, ApiStatus } from './sections/types'; // smol-toml is ESM-only, must use dynamic import async function importSmolToml() { return await import('smol-toml'); } -/** - * Webhook server connection status - */ -export interface WebhookStatus { - running: boolean; - version?: string; - port?: number; - workspace?: string; - sessionFile?: string; -} - -/** - * Operator REST API connection status - */ -export interface ApiStatus { - connected: boolean; - version?: string; - port?: number; - url?: string; -} - -/** Internal state for the Configuration section */ -interface ConfigState { - workingDirSet: boolean; - workingDir: string; - configExists: boolean; - configPath: string; -} - -/** Config-driven state for a single kanban provider */ -interface KanbanProviderState { - provider: 'jira' | 'linear'; - key: string; // domain for jira, teamId for linear - enabled: boolean; - displayName: string; // domain for jira, or team name - url: string; // e.g. "https://myorg.atlassian.net" - projects: Array<{ - key: string; // project key or "default" - collectionName: string; - url: string; // e.g. "https://myorg.atlassian.net/browse/PROJ" - }>; -} - -/** Internal state for the Kanban section */ -interface KanbanState { - configured: boolean; - providers: KanbanProviderState[]; -} - -/** Internal state for the LLM Tools section */ -interface LlmState { - detected: boolean; - tools: DetectedToolResult[]; - configDetected: Array<{ name: string; version?: string }>; -} - -/** Internal state for the Git section */ -interface GitState { - configured: boolean; - provider?: string; - githubEnabled?: boolean; - tokenSet?: boolean; - branchFormat?: string; - useWorktrees?: boolean; -} - /** * TreeDataProvider for hierarchical status information */ @@ -111,24 +46,51 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { private context: vscode.ExtensionContext; private parsedConfig: Record | null = null; - - private webhookStatus: WebhookStatus = { running: false }; - private apiStatus: ApiStatus = { connected: false }; - private operatorVersion: string | undefined; private ticketsDir: string | undefined; - - private configState: ConfigState = { - workingDirSet: false, - workingDir: '', - configExists: false, - configPath: '', - }; - private kanbanState: KanbanState = { configured: false, providers: [] }; - private llmState: LlmState = { detected: false, tools: [], configDetected: [] }; - private gitState: GitState = { configured: false }; + private webhookServerRef?: { isRunning: () => boolean; getPort: () => number }; + + // Named section references for progressive disclosure + private configSection: ConfigSection; + private connectionsSection: ConnectionsSection; + private kanbanSection: KanbanSection; + private llmSection: LlmSection; + private gitSection: GitSection; + private issueTypeSection: IssueTypeSection; + private delegatorSection: DelegatorSection; + private managedProjectsSection: ManagedProjectsSection; + + // All sections for check() and routing + private allSections: StatusSection[]; + private sectionMap: Map; + private ctx: SectionContext; constructor(context: vscode.ExtensionContext) { this.context = context; + this.configSection = new ConfigSection(); + this.connectionsSection = new ConnectionsSection(); + this.kanbanSection = new KanbanSection(); + this.llmSection = new LlmSection(); + this.gitSection = new GitSection(); + this.issueTypeSection = new IssueTypeSection(); + this.delegatorSection = new DelegatorSection(); + this.managedProjectsSection = new ManagedProjectsSection(); + + this.allSections = [ + this.configSection, + this.connectionsSection, + this.kanbanSection, + this.llmSection, + this.gitSection, + this.issueTypeSection, + this.delegatorSection, + this.managedProjectsSection, + ]; + this.sectionMap = new Map(this.allSections.map(s => [s.sectionId, s])); + this.ctx = this.buildContext(); + } + + setWebhookServer(server: { isRunning: () => boolean; getPort: () => number }): void { + this.webhookServerRef = server; } async setTicketsDir(dir: string | undefined): Promise { @@ -138,16 +100,18 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { async refresh(): Promise { this.parsedConfig = null; + const ctx = this.buildContext(); - await Promise.allSettled([ - this.checkConfigState(), - this.checkKanbanState(), - this.checkLlmState(), - this.checkGitState(), - this.checkWebhookStatus(), - this.checkApiStatus(), - this.checkOperatorVersion(), - ]); + // All sections run check() regardless of visibility + await Promise.allSettled(this.allSections.map(s => s.check(ctx))); + + // Set readiness flags after checks complete + ctx.configReady = this.configSection.isReady(); + ctx.connectionsReady = this.connectionsSection.isConfigured(); + ctx.kanbanConfigured = this.kanbanSection.isConfigured(); + ctx.llmConfigured = this.llmSection.isConfigured(); + ctx.gitConfigured = this.gitSection.isConfigured(); + this.ctx = ctx; this._onDidChangeTreeData.fire(undefined); } @@ -181,266 +145,40 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { return this.parsedConfig; } - /** - * Check Configuration section state - */ - private async checkConfigState(): Promise { - const workingDir = this.context.globalState.get('operator.workingDirectory') - || resolveWorkingDirectory(); - const workingDirSet = !!workingDir; - const configExists = await configFileExists(); - const configPath = getResolvedConfigPath(); - - this.configState = { - workingDirSet, - workingDir: workingDir || '', - configExists, - configPath: configPath || '', + private buildContext(): SectionContext { + return { + extensionContext: this.context, + ticketsDir: this.ticketsDir, + readConfigToml: () => this.readConfigToml(), + configReady: false, + connectionsReady: false, + kanbanConfigured: false, + llmConfigured: false, + gitConfigured: false, + webhookServer: this.webhookServerRef, }; } /** - * Check Kanban section state from config.toml, falling back to env vars + * Build the list of sections visible based on current readiness flags. */ - private async checkKanbanState(): Promise { - const config = await this.readConfigToml(); - const kanbanSection = config.kanban as Record | undefined; - const providers: KanbanProviderState[] = []; - - if (kanbanSection) { - // Parse Jira providers from config.toml - const jiraSection = kanbanSection.jira as Record | undefined; - if (jiraSection) { - for (const [domain, wsConfig] of Object.entries(jiraSection)) { - const ws = wsConfig as Record; - if (ws.enabled === false) { continue; } - const projects: KanbanProviderState['projects'] = []; - const projectsSection = ws.projects as Record | undefined; - if (projectsSection) { - for (const [projectKey, projConfig] of Object.entries(projectsSection)) { - const proj = projConfig as Record; - projects.push({ - key: projectKey, - collectionName: (proj.collection_name as string) || 'dev_kanban', - url: `https://${domain}/browse/${projectKey}`, - }); - } - } - providers.push({ - provider: 'jira', - key: domain, - enabled: ws.enabled !== false, - displayName: domain, - url: `https://${domain}`, - projects, - }); - } - } - - // Parse Linear providers from config.toml - const linearSection = kanbanSection.linear as Record | undefined; - if (linearSection) { - for (const [teamId, wsConfig] of Object.entries(linearSection)) { - const ws = wsConfig as Record; - if (ws.enabled === false) { continue; } - const projects: KanbanProviderState['projects'] = []; - const projectsSection = ws.projects as Record | undefined; - if (projectsSection) { - for (const [projectKey, projConfig] of Object.entries(projectsSection)) { - const proj = projConfig as Record; - projects.push({ - key: projectKey, - collectionName: (proj.collection_name as string) || 'dev_kanban', - url: `https://linear.app/team/${projectKey}`, - }); - } - } - providers.push({ - provider: 'linear', - key: teamId, - enabled: ws.enabled !== false, - displayName: teamId, - url: 'https://linear.app', - projects, - }); - } - } - } - - // Fall back to env-var-based detection if config.toml has no kanban section - if (providers.length === 0) { - const workspaces = await getKanbanWorkspaces(); - for (const ws of workspaces) { - providers.push({ - provider: ws.provider, - key: ws.name, - enabled: ws.configured, - displayName: ws.name, - url: ws.url, - projects: [], - }); - } - } + private getVisibleSections(): StatusSection[] { + const visible: StatusSection[] = [this.configSection]; - this.kanbanState = { - configured: providers.length > 0, - providers, - }; - } + // Tier 1: requires config ready + if (!this.ctx.configReady) { return visible; } + visible.push(this.connectionsSection); - /** - * Check LLM Tools section state - */ - private async checkLlmState(): Promise { - const tools = await detectInstalledLlmTools(); + // Tier 2: requires connections ready (API or webhook) + if (!this.ctx.connectionsReady) { return visible; } + visible.push(this.kanbanSection, this.llmSection, this.gitSection); - // Also check config.toml for richer detected tool info - const config = await this.readConfigToml(); - const llmTools = config.llm_tools as Record | undefined; - const configDetected = Array.isArray(llmTools?.detected) - ? (llmTools.detected as Array).map( - (entry) => { - if (typeof entry === 'string') { - return { name: entry }; - } - return { name: entry.name, version: entry.version }; - } - ) - : []; + // Tier 3: each requires its parent tier-2 section configured + if (this.ctx.kanbanConfigured) { visible.push(this.issueTypeSection); } + if (this.ctx.llmConfigured) { visible.push(this.delegatorSection); } + if (this.ctx.gitConfigured) { visible.push(this.managedProjectsSection); } - this.llmState = { - detected: tools.length > 0 || configDetected.length > 0, - tools, - configDetected, - }; - } - - /** - * Check Git section state - */ - private async checkGitState(): Promise { - const config = await this.readConfigToml(); - const gitSection = config.git as Record | undefined; - - if (!gitSection) { - this.gitState = { configured: false }; - return; - } - - const provider = gitSection.provider as string | undefined; - const github = gitSection.github as Record | undefined; - const githubEnabled = github?.enabled as boolean | undefined; - const branchFormat = gitSection.branch_format as string | undefined; - const useWorktrees = gitSection.use_worktrees as boolean | undefined; - - // Check GitHub token from env - const tokenEnv = (github?.token_env as string) || 'GITHUB_TOKEN'; - const tokenSet = !!process.env[tokenEnv]; - - const configured = !!(provider || githubEnabled); - - this.gitState = { - configured, - provider, - githubEnabled, - tokenSet, - branchFormat, - useWorktrees, - }; - } - - /** - * Check webhook server status via session file - */ - private async checkWebhookStatus(): Promise { - if (!this.ticketsDir) { - this.webhookStatus = { running: false }; - return; - } - - const webhookSessionFile = path.join(this.ticketsDir, 'operator', 'vscode-session.json'); - try { - const content = await fs.readFile(webhookSessionFile, 'utf-8'); - const session = JSON.parse(content) as SessionInfo; - - this.webhookStatus = { - running: true, - version: session.version, - port: session.port, - workspace: session.workspace, - sessionFile: webhookSessionFile, - }; - } catch { - this.webhookStatus = { running: false }; - } - } - - /** - * Check API status - */ - private async checkApiStatus(): Promise { - if (this.ticketsDir) { - const apiSessionFile = path.join(this.ticketsDir, 'operator', 'api-session.json'); - try { - const content = await fs.readFile(apiSessionFile, 'utf-8'); - const session = JSON.parse(content) as ApiSessionInfo; - const apiUrl = `http://localhost:${session.port}`; - - if (await this.tryHealthCheck(apiUrl, session.version)) { - return; - } - } catch { - // Fall through - } - } - - const apiUrl = await discoverApiUrl(this.ticketsDir); - await this.tryHealthCheck(apiUrl); - } - - /** - * Check for locally installed operator version, or fetch latest from remote - */ - private async checkOperatorVersion(): Promise { - const operatorPath = await getOperatorPath(this.context); - if (operatorPath) { - this.operatorVersion = await getOperatorVersion(operatorPath) || undefined; - return; - } - - // No binary installed — fetch latest version from remote - try { - const response = await fetch('https://operator.untra.io/VERSION'); - if (response.ok) { - this.operatorVersion = (await response.text()).trim() || undefined; - } - } catch { - this.operatorVersion = undefined; - } - } - - /** - * Attempt a health check against the given API URL - */ - private async tryHealthCheck(apiUrl: string, sessionVersion?: string): Promise { - try { - const response = await fetch(`${apiUrl}/api/v1/health`); - if (response.ok) { - const health = await response.json() as { version?: string }; - const port = new URL(apiUrl).port; - this.apiStatus = { - connected: true, - version: health.version || sessionVersion, - port: port ? parseInt(port, 10) : 7008, - url: apiUrl, - }; - return true; - } - } catch { - // Health check failed - } - this.apiStatus = { connected: false }; - return false; + return visible; } getTreeItem(element: StatusItem): vscode.TreeItem { @@ -449,579 +187,15 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { getChildren(element?: StatusItem): StatusItem[] { if (!element) { - return this.getTopLevelSections(); + return this.getVisibleSections().map(s => s.getTopLevelItem(this.ctx)); } - switch (element.sectionId) { - case 'config': - return this.getConfigChildren(); - case 'kanban': - return this.getKanbanChildren(); - case 'llm': - return this.getLlmChildren(); - case 'git': - return this.getGitChildren(); - case 'connections': - return this.getConnectionsChildren(); - } - - // Workspace-level items return their project children - if (element.provider && element.workspaceKey && !element.projectKey) { - return this.getKanbanProjectChildren(element.provider, element.workspaceKey); + // Route to section by sectionId + const section = element.sectionId ? this.sectionMap.get(element.sectionId) : undefined; + if (section) { + return section.getChildren(this.ctx, element); } return []; } - - /** - * Top-level collapsible sections - */ - private getTopLevelSections(): StatusItem[] { - const configuredBoth = this.configState.workingDirSet && this.configState.configExists; - - // Determine the right command when not fully configured - const configCommand = !configuredBoth - ? this.configState.workingDirSet - ? { - command: 'operator.runSetup', - title: 'Run Operator Setup', - } - : { - command: 'operator.selectWorkingDirectory', - title: 'Select Working Directory', - } - : undefined; - - return [ - // 1. Configuration - new StatusItem({ - label: 'Configuration', - description: configuredBoth - ? path.basename(this.configState.workingDir) - : 'Setup required', - icon: configuredBoth ? 'check' : 'debug-configure', - collapsibleState: configuredBoth - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.Expanded, - sectionId: 'config', - command: configCommand, - }), - // 2. Connections - new StatusItem({ - label: 'Connections', - description: configuredBoth ? this.getConnectionsSummary() : 'Not Ready', - icon: configuredBoth ? this.getConnectionsIcon() : 'debug-configure', - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - sectionId: 'connections', - command: configuredBoth ? undefined : configCommand, - }), - - // 3. Kanban Providers - new StatusItem({ - label: 'Kanban', - description: this.kanbanState.configured - ? this.getKanbanSummary() - : 'No provider connected', - icon: this.kanbanState.configured ? 'check' : 'warning', - collapsibleState: this.kanbanState.configured - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.Expanded, - sectionId: 'kanban', - command: this.kanbanState.configured ? undefined : { - command: 'operator.startKanbanOnboarding', - title: 'Configure Kanban', - }, - }), - - // 4. LLM Tools - new StatusItem({ - label: 'LLM Tools', - description: this.llmState.detected - ? this.getLlmSummary() - : 'No tools detected', - icon: this.llmState.detected ? 'check' : 'warning', - collapsibleState: this.llmState.detected - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.Expanded, - sectionId: 'llm', - command: this.llmState.detected ? undefined : { - command: 'operator.detectLlmTools', - title: 'Detect LLM Tools', - }, - }), - - // 5. Git Provider - new StatusItem({ - label: 'Git', - description: this.gitState.configured - ? (this.gitState.provider || 'GitHub') - : 'Not configured', - icon: this.gitState.configured ? 'check' : 'warning', - collapsibleState: this.gitState.configured - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.Expanded, - sectionId: 'git', - command: this.gitState.configured ? undefined : { - command: 'operator.openSettings', - title: 'Open Settings', - }, - }), - ]; - } - - // ─── Section Children ────────────────────────────────────────────────── - - private getConfigChildren(): StatusItem[] { - const items: StatusItem[] = []; - - // Working Directory - items.push(new StatusItem({ - label: 'Working Directory', - description: this.configState.workingDirSet - ? this.configState.workingDir - : 'Not set', - icon: this.configState.workingDirSet ? 'folder-opened' : 'folder', - command: { - command: 'operator.selectWorkingDirectory', - title: 'Select Working Directory', - }, - })); - - // Config File - items.push(new StatusItem({ - label: 'Config File', - description: this.configState.configExists - ? this.configState.configPath - : 'Not found', - icon: this.configState.configExists ? 'file' : 'file-add', - command: { - command: 'operator.openSettings', - title: 'Open Settings', - }, - })); - - // Tickets directory — click reveals in file explorer - if (this.ticketsDir) { - items.push(new StatusItem({ - label: 'Tickets', - description: this.ticketsDir, - icon: 'markdown', - command: { - command: 'operator.revealTicketsDir', - title: 'Reveal in Explorer', - }, - })); - } else { - items.push(new StatusItem({ - label: 'Tickets', - description: 'Not found', - icon: 'markdown', - tooltip: 'No .tickets directory found', - })); - } - - return items; - } - - private getKanbanChildren(): StatusItem[] { - const items: StatusItem[] = []; - - if (this.kanbanState.configured) { - // One collapsible item per workspace - for (const prov of this.kanbanState.providers) { - const providerLabel = prov.provider === 'jira' ? 'Jira' : 'Linear'; - const providerIcon = prov.provider === 'jira' ? 'operator-atlassian' : 'operator-linear'; - items.push(new StatusItem({ - label: providerLabel, - description: prov.displayName, - icon: providerIcon, - tooltip: prov.url, - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - command: { - command: 'vscode.open', - title: 'Open in Browser', - arguments: [vscode.Uri.parse(prov.url)], - }, - contextValue: 'kanbanWorkspace', - provider: prov.provider, - workspaceKey: prov.key, - })); - } - - // Add provider action - items.push(new StatusItem({ - label: 'Add Provider', - icon: 'add', - command: { - command: 'operator.startKanbanOnboarding', - title: 'Add Kanban Provider', - }, - })); - } else { - // Nudge items - items.push(new StatusItem({ - label: 'Configure Jira', - icon: 'operator-atlassian', - command: { - command: 'operator.configureJira', - title: 'Configure Jira', - }, - })); - items.push(new StatusItem({ - label: 'Configure Linear', - icon: 'operator-linear', - command: { - command: 'operator.configureLinear', - title: 'Configure Linear', - }, - })); - } - - return items; - } - - private getKanbanProjectChildren(provider: string, workspaceKey: string): StatusItem[] { - const items: StatusItem[] = []; - const prov = this.kanbanState.providers.find( - (p) => p.provider === provider && p.key === workspaceKey - ); - if (!prov) { return items; } - - // Project/team sync config items - for (const proj of prov.projects) { - items.push(new StatusItem({ - label: proj.key, - description: proj.collectionName, - icon: 'project', - tooltip: proj.url, - command: { - command: 'vscode.open', - title: 'Open in Browser', - arguments: [vscode.Uri.parse(proj.url)], - }, - contextValue: 'kanbanSyncConfig', - provider: prov.provider, - workspaceKey: prov.key, - projectKey: proj.key, - })); - } - - // Add Project / Add Team button - const addLabel = provider === 'jira' ? 'Add Jira Project' : 'Add Linear Workspace'; - const addCommand = provider === 'jira' ? 'operator.addJiraProject' : 'operator.addLinearTeam'; - items.push(new StatusItem({ - label: addLabel, - icon: 'add', - command: { - command: addCommand, - title: addLabel, - arguments: [workspaceKey], - }, - })); - - return items; - } - - private getLlmChildren(): StatusItem[] { - const items: StatusItem[] = []; - - if (this.llmState.detected) { - // Show config-detected tools first (richer info) - const shown = new Set(); - - for (const tool of this.llmState.configDetected) { - shown.add(tool.name); - const icon = `operator-${tool.name}`; - items.push(new StatusItem({ - label: tool.name, - description: tool.version, - icon: icon, - })); - } - - // Show PATH-detected tools not already in config - for (const tool of this.llmState.tools) { - if (!shown.has(tool.name)) { - const icon = `operator-${tool.name}`; - items.push(new StatusItem({ - label: tool.name, - description: tool.version !== 'unknown' ? tool.version : undefined, - icon: icon, - })); - } - } - - // Detect action - items.push(new StatusItem({ - label: 'Detect Tools', - icon: 'search', - command: { - command: 'operator.detectLlmTools', - title: 'Detect LLM Tools', - }, - })); - } else { - // Nudge items - items.push(new StatusItem({ - label: 'Detect Tools', - icon: 'search', - command: { - command: 'operator.detectLlmTools', - title: 'Detect LLM Tools', - }, - })); - items.push(new StatusItem({ - label: 'Install Claude Code', - icon: 'link-external', - command: { - command: 'vscode.open', - title: 'Install Claude Code', - arguments: [vscode.Uri.parse('https://docs.anthropic.com/en/docs/claude-code')], - }, - })); - } - - return items; - } - - private getGitChildren(): StatusItem[] { - const items: StatusItem[] = []; - - if (this.gitState.configured) { - // Provider - items.push(new StatusItem({ - label: 'Provider', - description: this.gitState.provider || 'GitHub', - icon: 'source-control', - })); - - // GitHub Token - items.push(new StatusItem({ - label: 'GitHub Token', - description: this.gitState.tokenSet ? 'Set' : 'Not set', - icon: 'key', - })); - - // Branch Format - if (this.gitState.branchFormat) { - items.push(new StatusItem({ - label: 'Branch Format', - description: this.gitState.branchFormat, - icon: 'git-branch', - })); - } - - // Worktrees - items.push(new StatusItem({ - label: 'Worktrees', - description: this.gitState.useWorktrees ? 'Enabled' : 'Disabled', - icon: 'git-merge', - })); - } else { - // Nudge item - items.push(new StatusItem({ - label: 'Open Settings', - icon: 'gear', - command: { - command: 'operator.openSettings', - title: 'Open Settings', - }, - })); - } - - return items; - } - - private getConnectionsChildren(): StatusItem[] { - const configuredBoth = this.configState.workingDirSet && this.configState.configExists; - - // 1. API Version — always shown - let versionItem: StatusItem; - if (this.apiStatus.connected && this.apiStatus.version) { - const swaggerUrl = `http://localhost:${this.apiStatus.port || 7008}/swagger-ui`; - versionItem = new StatusItem({ - label: 'Operator', - description: 'Version ' + this.apiStatus.version, - icon: 'versions', - tooltip: 'Open Swagger UI', - command: { - command: 'vscode.open', - title: 'Open Swagger UI', - arguments: [vscode.Uri.parse(swaggerUrl)], - }, - }); - } else { - versionItem = new StatusItem({ - label: 'Operator Version', - description: this.operatorVersion ? 'Version ' + this.operatorVersion : 'Not installed', - icon: 'versions', - tooltip: this.operatorVersion - ? `Installed: ${this.operatorVersion} — click to update` - : 'Click to download Operator', - command: { - command: 'operator.downloadOperator', - title: 'Download Operator', - }, - }); - } - - // 2. API Connection — always shown - const apiItem = this.apiStatus.connected - ? new StatusItem({ - label: 'API', - description: this.apiStatus.url || 'Connected', - icon: 'pass', - tooltip: `Operator REST API at ${this.apiStatus.url}`, - }) - : new StatusItem({ - label: 'API', - description: configuredBoth ? 'Disconnected' : 'Not Ready', - icon: 'error', - tooltip: configuredBoth - ? 'Click to start Operator API server' - : 'Complete configuration first', - command: configuredBoth ? { - command: 'operator.startOperatorServer', - title: 'Start Operator Server', - } : undefined, - }); - - // 3. Webhook Connection — always shown - const webhookItem = this.webhookStatus.running - ? new StatusItem({ - label: 'Webhook', - description: `Running${this.webhookStatus.port ? ` :${this.webhookStatus.port}` : ''}`, - icon: 'pass', - tooltip: 'Local webhook server for terminal management', - }) - : new StatusItem({ - label: 'Webhook', - description: configuredBoth ? 'Stopped' : 'Not Ready', - icon: 'circle-slash', - tooltip: configuredBoth - ? 'Click to start webhook server' - : 'Complete configuration first', - command: configuredBoth ? { - command: 'operator.startWebhookServer', - title: 'Start Webhook Server', - } : undefined, - }); - - // 4. MCP Connection - const mcpItem = this.apiStatus.connected - ? new StatusItem({ - label: 'MCP', - description: 'Connect', - icon: 'plug', - tooltip: 'Connect Operator as MCP server in VS Code', - command: { - command: 'operator.connectMcpServer', - title: 'Connect MCP Server', - }, - }) - : new StatusItem({ - label: 'MCP', - description: 'API required', - icon: 'circle-slash', - tooltip: 'Start the Operator API to enable MCP connection', - }); - - return [versionItem, apiItem, webhookItem, mcpItem]; - } - - // ─── Summary Helpers ─────────────────────────────────────────────────── - - private getKanbanSummary(): string { - const prov = this.kanbanState.providers[0]; - if (!prov) { - return ''; - } - const provider = prov.provider === 'jira' ? 'Jira' : 'Linear'; - return `${provider}: ${prov.displayName}`; - } - - private getLlmSummary(): string { - // Prefer config-detected (has version info) - if (this.llmState.configDetected.length > 0) { - const first = this.llmState.configDetected[0]!; - return first.version ? `${first.name} v${first.version}` : first.name; - } - // Fall back to PATH-detected - if (this.llmState.tools.length > 0) { - const first = this.llmState.tools[0]!; - return first.version !== 'unknown' ? `${first.name} v${first.version}` : first.name; - } - return ''; - } - - private getConnectionsSummary(): string { - if (this.apiStatus.connected && this.webhookStatus.running) { - return 'All connected'; - } - if (this.apiStatus.connected || this.webhookStatus.running) { - return 'Partial'; - } - return 'Disconnected'; - } - - private getConnectionsIcon(): string { - if (this.apiStatus.connected && this.webhookStatus.running) { - return 'pass'; - } - if (this.apiStatus.connected || this.webhookStatus.running) { - return 'warning'; - } - return 'error'; - } -} - -/** - * StatusItem options - */ -interface StatusItemOptions { - label: string; - description?: string; - icon: string; - tooltip?: string; - collapsibleState?: vscode.TreeItemCollapsibleState; - command?: vscode.Command; - sectionId?: string; - contextValue?: string; // for view/item/context when clause - provider?: string; // 'jira' | 'linear' - workspaceKey?: string; // domain or teamId (config key) - projectKey?: string; // project/team sync config key -} - -/** - * TreeItem for status display - */ -export class StatusItem extends vscode.TreeItem { - public readonly sectionId?: string; - public readonly provider?: string; - public readonly workspaceKey?: string; - public readonly projectKey?: string; - - constructor(opts: StatusItemOptions) { - super( - opts.label, - opts.collapsibleState ?? vscode.TreeItemCollapsibleState.None - ); - this.sectionId = opts.sectionId; - this.provider = opts.provider; - this.workspaceKey = opts.workspaceKey; - this.projectKey = opts.projectKey; - if (opts.description !== undefined) { - this.description = opts.description; - } - this.tooltip = opts.tooltip || (opts.description - ? `${opts.label}: ${opts.description}` - : opts.label); - this.iconPath = new vscode.ThemeIcon(opts.icon); - if (opts.command) { - this.command = opts.command; - } - if (opts.contextValue) { - this.contextValue = opts.contextValue; - } - } } diff --git a/vscode-extension/src/webhook-server.ts b/vscode-extension/src/webhook-server.ts index 3868a91..405ce18 100644 --- a/vscode-extension/src/webhook-server.ts +++ b/vscode-extension/src/webhook-server.ts @@ -157,6 +157,14 @@ export class WebhookServer { return this.actualPort; } + /** + * Re-write the session file if the server is running but the file was lost + */ + async ensureSessionFile(ticketsDir: string): Promise { + if (!this.server) { return; } + await this.writeSessionFile(ticketsDir); + } + /** * Get the configured port preference */ diff --git a/vscode-extension/test/suite/api-client.test.ts b/vscode-extension/test/suite/api-client.test.ts index e859838..85ce743 100644 --- a/vscode-extension/test/suite/api-client.test.ts +++ b/vscode-extension/test/suite/api-client.test.ts @@ -216,6 +216,7 @@ suite('API Client Test Suite', () => { ); const options: LaunchTicketRequest = { + delegator: null, provider: 'claude', model: 'sonnet', yolo_mode: true, @@ -269,6 +270,7 @@ suite('API Client Test Suite', () => { ); const options: LaunchTicketRequest = { + delegator: null, provider: null, model: null, yolo_mode: false, @@ -297,6 +299,7 @@ suite('API Client Test Suite', () => { ); const options: LaunchTicketRequest = { + delegator: null, provider: null, model: null, yolo_mode: false, @@ -319,6 +322,7 @@ suite('API Client Test Suite', () => { ); const options: LaunchTicketRequest = { + delegator: null, provider: null, model: null, yolo_mode: false, diff --git a/vscode-extension/test/suite/mcp-connect.test.ts b/vscode-extension/test/suite/mcp-connect.test.ts index 1c4b73b..a085901 100644 --- a/vscode-extension/test/suite/mcp-connect.test.ts +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -1,8 +1,7 @@ /** * Tests for mcp-connect.ts * - * Tests MCP descriptor fetching, deep link building, and the - * connectMcpServer flow. + * Tests MCP descriptor fetching and server registration check. */ import * as assert from 'assert'; @@ -11,7 +10,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { fetchMcpDescriptor, - buildMcpDeepLink, McpDescriptorResponse, } from '../../src/mcp-connect'; @@ -26,13 +24,6 @@ const fixturesDir = path.join( 'api' ); -/** Shape of the decoded MCP config embedded in deep link URIs */ -interface McpDeepLinkConfig { - name: string; - type: string; - url: string; -} - suite('MCP Connect Test Suite', () => { let fetchStub: sinon.SinonStub; @@ -124,70 +115,4 @@ suite('MCP Connect Test Suite', () => { ); }); }); - - suite('buildMcpDeepLink()', () => { - test('builds correct vscode:// URI', () => { - const descriptor: McpDescriptorResponse = { - server_name: 'operator', - server_id: 'operator-mcp', - version: '0.1.26', - transport_url: 'http://localhost:7008/api/v1/mcp/sse', - label: 'Operator MCP Server', - openapi_url: 'http://localhost:7008/api-docs/openapi.json', - }; - - const uri = buildMcpDeepLink(descriptor); - - assert.strictEqual(uri.scheme, 'vscode'); - assert.strictEqual(uri.authority, 'modelcontextprotocol.mcp'); - assert.strictEqual(uri.path, '/connect'); - }); - - test('encodes correct config in base64', () => { - const descriptor: McpDescriptorResponse = { - server_name: 'operator', - server_id: 'operator-mcp', - version: '0.1.26', - transport_url: 'http://localhost:7008/api/v1/mcp/sse', - label: 'Operator MCP Server', - openapi_url: null, - }; - - const uri = buildMcpDeepLink(descriptor); - const query = uri.query; - - // Extract base64 config from query - assert.ok(query.startsWith('config='), 'Query should start with config='); - const base64 = query.replace('config=', ''); - const decoded = JSON.parse(Buffer.from(base64, 'base64').toString()) as McpDeepLinkConfig; - - assert.strictEqual(decoded.name, 'operator'); - assert.strictEqual(decoded.type, 'sse'); - assert.strictEqual( - decoded.url, - 'http://localhost:7008/api/v1/mcp/sse' - ); - }); - - test('uses server_name from descriptor', () => { - const descriptor: McpDescriptorResponse = { - server_name: 'custom-operator', - server_id: 'custom-mcp', - version: '1.0.0', - transport_url: 'http://localhost:9999/api/v1/mcp/sse', - label: 'Custom MCP', - openapi_url: null, - }; - - const uri = buildMcpDeepLink(descriptor); - const base64 = uri.query.replace('config=', ''); - const decoded = JSON.parse(Buffer.from(base64, 'base64').toString()) as McpDeepLinkConfig; - - assert.strictEqual(decoded.name, 'custom-operator'); - assert.strictEqual( - decoded.url, - 'http://localhost:9999/api/v1/mcp/sse' - ); - }); - }); }); diff --git a/vscode-extension/test/suite/status-provider.test.ts b/vscode-extension/test/suite/status-provider.test.ts new file mode 100644 index 0000000..cc3811e --- /dev/null +++ b/vscode-extension/test/suite/status-provider.test.ts @@ -0,0 +1,473 @@ +/** + * Tests for status-provider.ts + * + * Tests webhook status icon rendering and working directory item behavior. + * Uses real temp directories for file-system-dependent checks and sinon + * stubs for external dependencies (network, binary discovery, etc.). + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { StatusTreeProvider, StatusItem } from '../../src/status-provider'; +import * as configPaths from '../../src/config-paths'; +import * as walkthrough from '../../src/walkthrough'; +import * as operatorBinary from '../../src/operator-binary'; +import * as apiClient from '../../src/api-client'; + +/** + * Create a mock ExtensionContext with stubbed globalState + */ +function createMockContext( + sandbox: sinon.SinonSandbox, + workingDir?: string +): vscode.ExtensionContext { + const getStub = sandbox.stub(); + getStub.withArgs('operator.workingDirectory').returns(workingDir ?? ''); + + return { + globalState: { + get: getStub, + update: sandbox.stub().resolves(), + keys: sandbox.stub().returns([]), + setKeysForSync: sandbox.stub(), + }, + subscriptions: [], + extensionPath: '/fake/extension', + extensionUri: vscode.Uri.file('/fake/extension'), + globalStorageUri: vscode.Uri.file('/fake/storage'), + storageUri: vscode.Uri.file('/fake/workspace-storage'), + logUri: vscode.Uri.file('/fake/log'), + extensionMode: vscode.ExtensionMode.Test, + extension: {} as vscode.Extension, + environmentVariableCollection: {} as vscode.GlobalEnvironmentVariableCollection, + secrets: {} as vscode.SecretStorage, + storagePath: '/fake/workspace-storage', + globalStoragePath: '/fake/storage', + logPath: '/fake/log', + asAbsolutePath: (p: string) => p, + languageModelAccessInformation: {} as vscode.LanguageModelAccessInformation, + } as unknown as vscode.ExtensionContext; +} + +/** Helper to find a child item by label */ +function findChild(items: StatusItem[], label: string): StatusItem | undefined { + return items.find((item) => item.label === label); +} + +/** Helper to extract section labels from top-level items */ +function getSectionLabels(items: StatusItem[]): string[] { + return items.map((item) => item.label as string); +} + +suite('Status Provider Test Suite', () => { + let sandbox: sinon.SinonSandbox; + let tempDir: string; + + setup(async () => { + sandbox = sinon.createSandbox(); + + // Create temp directory for session files + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'status-provider-test-')); + + // Stub external dependencies that make network calls or spawn processes + sandbox.stub(walkthrough, 'detectInstalledLlmTools').resolves([]); + sandbox.stub(walkthrough, 'getKanbanWorkspaces').resolves([]); + sandbox.stub(operatorBinary, 'getOperatorPath').resolves(undefined); + sandbox.stub(operatorBinary, 'getOperatorVersion').resolves(undefined); + sandbox.stub(apiClient, 'discoverApiUrl').resolves('http://localhost:7008'); + sandbox.stub(global, 'fetch').rejects(new Error('no network in tests')); + }); + + teardown(async () => { + sandbox.restore(); + try { + await fs.rm(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + }); + + suite('webhook status rendering', () => { + test('shows pass icon and Running description when webhook is running', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // Write a real session file in the temp directory + const operatorDir = path.join(tempDir, 'operator'); + await fs.mkdir(operatorDir, { recursive: true }); + await fs.writeFile( + path.join(operatorDir, 'vscode-session.json'), + JSON.stringify({ + wrapper: 'vscode', + port: 7009, + pid: 12345, + version: '0.1.26', + startedAt: '2024-01-01T00:00:00Z', + workspace: '/fake/workspace', + }) + ); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const sections = provider.getChildren(); + const connections = findChild(sections, 'Connections'); + assert.ok(connections, 'Should have Connections section'); + + const children = provider.getChildren(connections); + const webhook = findChild(children, 'Webhook'); + assert.ok(webhook, 'Should have Webhook item'); + + const icon = webhook.iconPath as vscode.ThemeIcon; + assert.strictEqual(icon.id, 'pass', 'Webhook icon should be pass when running'); + assert.ok( + (webhook.description as string).includes('Running'), + `Description "${webhook.description}" should contain "Running"` + ); + assert.ok( + (webhook.description as string).includes(':7009'), + `Description "${webhook.description}" should contain port ":7009"` + ); + }); + + test('shows circle-slash icon and Stopped when webhook is not running', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // No session file — webhook not running + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const sections = provider.getChildren(); + const connections = findChild(sections, 'Connections'); + assert.ok(connections, 'Should have Connections section'); + + const children = provider.getChildren(connections); + const webhook = findChild(children, 'Webhook'); + assert.ok(webhook, 'Should have Webhook item'); + + const icon = webhook.iconPath as vscode.ThemeIcon; + assert.strictEqual(icon.id, 'circle-slash', 'Webhook icon should be circle-slash when stopped'); + assert.strictEqual(webhook.description, 'Stopped', 'Description should be "Stopped"'); + }); + }); + + suite('working directory item', () => { + test('has contextValue and no command when working directory is set', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const sections = provider.getChildren(); + const config = findChild(sections, 'Configuration'); + assert.ok(config, 'Should have Configuration section'); + + const children = provider.getChildren(config); + const workDir = findChild(children, 'Working Directory'); + assert.ok(workDir, 'Should have Working Directory item'); + + assert.strictEqual( + workDir.contextValue, + 'workingDirConfigured', + 'Should have contextValue "workingDirConfigured"' + ); + assert.strictEqual( + workDir.command, + undefined, + 'Should not have a click command when directory is set' + ); + assert.strictEqual(workDir.description, '/fake/working-dir'); + }); + + test('has click command and no contextValue when working directory is not set', async () => { + const mockContext = createMockContext(sandbox); + sandbox.stub(configPaths, 'configFileExists').resolves(false); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns(''); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const sections = provider.getChildren(); + const config = findChild(sections, 'Configuration'); + assert.ok(config, 'Should have Configuration section'); + + const children = provider.getChildren(config); + const workDir = findChild(children, 'Working Directory'); + assert.ok(workDir, 'Should have Working Directory item'); + + assert.ok(workDir.command, 'Should have a click command when directory is not set'); + assert.strictEqual( + workDir.command?.command, + 'operator.selectWorkingDirectory', + 'Command should be selectWorkingDirectory' + ); + assert.strictEqual(workDir.contextValue, undefined, 'Should not have contextValue'); + assert.strictEqual(workDir.description, 'Not set'); + }); + }); + + suite('session wrapper item', () => { + test('shows pass icon with VS Code Terminal when wrapper defaults to vscode and webhook running', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // Write webhook session file so webhook shows as running + const operatorDir = path.join(tempDir, 'operator'); + await fs.mkdir(operatorDir, { recursive: true }); + await fs.writeFile( + path.join(operatorDir, 'vscode-session.json'), + JSON.stringify({ + wrapper: 'vscode', + port: 7009, + pid: 12345, + version: '0.1.26', + startedAt: '2024-01-01T00:00:00Z', + workspace: '/fake/workspace', + }) + ); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const sections = provider.getChildren(); + const connections = findChild(sections, 'Connections'); + assert.ok(connections, 'Should have Connections section'); + + const children = provider.getChildren(connections); + const wrapper = findChild(children, 'Session Wrapper'); + assert.ok(wrapper, 'Should have Session Wrapper item'); + + const icon = wrapper.iconPath as vscode.ThemeIcon; + assert.strictEqual(icon.id, 'pass', 'Should show pass icon when vscode wrapper and webhook running'); + assert.strictEqual(wrapper.description, 'VS Code Terminal'); + }); + + test('shows warning icon when wrapper is not vscode', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // Write a config.toml with sessions.wrapper = "tmux" + const configPath = path.join(tempDir, 'config.toml'); + await fs.writeFile(configPath, '[sessions]\nwrapper = "tmux"\n'); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(configPath); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const sections = provider.getChildren(); + const connections = findChild(sections, 'Connections'); + assert.ok(connections, 'Should have Connections section'); + + const children = provider.getChildren(connections); + const wrapper = findChild(children, 'Session Wrapper'); + assert.ok(wrapper, 'Should have Session Wrapper item'); + + const icon = wrapper.iconPath as vscode.ThemeIcon; + assert.strictEqual(icon.id, 'warning', 'Should show warning icon for non-vscode wrapper'); + assert.strictEqual(wrapper.description, 'tmux'); + }); + }); + + suite('progressive disclosure', () => { + test('tier 0: only Configuration when config not ready', async () => { + const mockContext = createMockContext(sandbox); + sandbox.stub(configPaths, 'configFileExists').resolves(false); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns(''); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const labels = getSectionLabels(provider.getChildren()); + assert.deepStrictEqual(labels, ['Configuration']); + }); + + test('tier 1: Configuration + Connections when config ready but no connections', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const labels = getSectionLabels(provider.getChildren()); + assert.deepStrictEqual(labels, ['Configuration', 'Connections']); + }); + + test('tier 2: adds Kanban, LLM Tools, Git when connections ready', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // Write webhook session file so connections are ready + const operatorDir = path.join(tempDir, 'operator'); + await fs.mkdir(operatorDir, { recursive: true }); + await fs.writeFile( + path.join(operatorDir, 'vscode-session.json'), + JSON.stringify({ + wrapper: 'vscode', + port: 7009, + pid: 12345, + version: '0.1.26', + startedAt: '2024-01-01T00:00:00Z', + workspace: '/fake/workspace', + }) + ); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const labels = getSectionLabels(provider.getChildren()); + assert.deepStrictEqual( + labels, + ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Git'] + ); + }); + + test('tier 3: Issue Types appears when kanban configured', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // Write config.toml with kanban section + const configPath = path.join(tempDir, 'config.toml'); + await fs.writeFile(configPath, '[kanban.jira."test.atlassian.net"]\nenabled = true\n'); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(configPath); + + // Write webhook session so connections are ready + const operatorDir = path.join(tempDir, 'operator'); + await fs.mkdir(operatorDir, { recursive: true }); + await fs.writeFile( + path.join(operatorDir, 'vscode-session.json'), + JSON.stringify({ + wrapper: 'vscode', + port: 7009, + pid: 12345, + version: '0.1.26', + startedAt: '2024-01-01T00:00:00Z', + workspace: '/fake/workspace', + }) + ); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const labels = getSectionLabels(provider.getChildren()); + assert.ok(labels.includes('Issue Types'), 'Should include Issue Types when kanban configured'); + assert.ok(!labels.includes('Managed Projects'), 'Should not include Managed Projects when git not configured'); + }); + + test('tier 3: Managed Projects appears when git configured', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // Write config.toml with git section + const configPath = path.join(tempDir, 'config.toml'); + await fs.writeFile(configPath, '[git]\nprovider = "github"\n'); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(configPath); + + // Write webhook session so connections are ready + const operatorDir = path.join(tempDir, 'operator'); + await fs.mkdir(operatorDir, { recursive: true }); + await fs.writeFile( + path.join(operatorDir, 'vscode-session.json'), + JSON.stringify({ + wrapper: 'vscode', + port: 7009, + pid: 12345, + version: '0.1.26', + startedAt: '2024-01-01T00:00:00Z', + workspace: '/fake/workspace', + }) + ); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const labels = getSectionLabels(provider.getChildren()); + assert.ok(labels.includes('Managed Projects'), 'Should include Managed Projects when git configured'); + assert.ok(!labels.includes('Issue Types'), 'Should not include Issue Types when kanban not configured'); + }); + + test('all tiers: all sections visible when fully configured', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // Write config.toml with kanban + git sections + const configPath = path.join(tempDir, 'config.toml'); + await fs.writeFile( + configPath, + '[kanban.jira."test.atlassian.net"]\nenabled = true\n\n[git]\nprovider = "github"\n' + ); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(configPath); + + // Write webhook session so connections are ready + const operatorDir = path.join(tempDir, 'operator'); + await fs.mkdir(operatorDir, { recursive: true }); + await fs.writeFile( + path.join(operatorDir, 'vscode-session.json'), + JSON.stringify({ + wrapper: 'vscode', + port: 7009, + pid: 12345, + version: '0.1.26', + startedAt: '2024-01-01T00:00:00Z', + workspace: '/fake/workspace', + }) + ); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const labels = getSectionLabels(provider.getChildren()); + assert.deepStrictEqual( + labels, + ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Git', 'Issue Types', 'Managed Projects'] + ); + }); + + test('tier 3 not visible when connections disconnected even if kanban/git configured', async () => { + const mockContext = createMockContext(sandbox, '/fake/working-dir'); + sandbox.stub(configPaths, 'configFileExists').resolves(true); + sandbox.stub(configPaths, 'resolveWorkingDirectory').returns('/fake/working-dir'); + + // Write config.toml with kanban + git — but NO webhook session + const configPath = path.join(tempDir, 'config.toml'); + await fs.writeFile( + configPath, + '[kanban.jira."test.atlassian.net"]\nenabled = true\n\n[git]\nprovider = "github"\n' + ); + sandbox.stub(configPaths, 'getResolvedConfigPath').returns(configPath); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const labels = getSectionLabels(provider.getChildren()); + assert.deepStrictEqual( + labels, + ['Configuration', 'Connections'], + 'Should only show tier 0+1 when connections not ready' + ); + }); + }); +}); diff --git a/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx b/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx index 32e9576..7424d62 100644 --- a/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx +++ b/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx @@ -77,10 +77,13 @@ export function PrimaryConfigSection({ } > VS Code Terminal - tmux + tmux + cmux + zellij - - Designates how launched ticket work is wrapped when started from VS Code + + Only VS Code Terminal is available when running from the extension. + Other wrappers require running Operator from the CLI.
    From 1c5b63a075e3d56602ffd9452bd64fc778bc4b7d Mon Sep 17 00:00:00 2001 From: untra Date: Mon, 23 Mar 2026 18:39:03 -0600 Subject: [PATCH 5/6] git provider doc refinement, rust lints Co-Authored-By: Claude Opus 4.5 --- docs/getting-started/git/github.md | 61 ++++---- docs/getting-started/git/gitlab.md | 139 +++++++++++++------ docs/getting-started/git/index.md | 28 +++- docs/getting-started/git/provider-support.md | 40 ++++-- src/rest/routes/llm_tools.rs | 4 +- src/ui/dashboard.rs | 3 +- 6 files changed, 182 insertions(+), 93 deletions(-) diff --git a/docs/getting-started/git/github.md b/docs/getting-started/git/github.md index 6258718..69d6efa 100644 --- a/docs/getting-started/git/github.md +++ b/docs/getting-started/git/github.md @@ -2,6 +2,7 @@ title: "GitHub" description: "Configure GitHub integration with Operator." layout: doc +published: true --- # GitHub @@ -19,7 +20,7 @@ Connect Operator to GitHub for repository management and pull requests. ## Install GitHub CLI -The `gh` CLI handles authentication and API operations. +The `gh` CLI handles authentication and API operations. Operator uses `gh` directly rather than raw API calls. ### macOS @@ -35,6 +36,9 @@ curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null sudo apt update sudo apt install gh + +# Fedora/RHEL +sudo dnf install gh ``` ### Windows @@ -43,20 +47,19 @@ sudo apt install gh winget install --id GitHub.cli ``` -### Authenticate +## Authenticate + +The `gh` CLI manages authentication, including OAuth flows and credential storage: ```bash gh auth login ``` -Follow the prompts to authenticate via browser or token. - -## Create Personal Access Token +Follow the prompts to authenticate via browser or token. Verify with: -1. Go to GitHub Settings > Developer settings > Personal access tokens -2. Click "Generate new token (classic)" -3. Select scopes: `repo`, `workflow` -4. Copy the token +```bash +gh auth status +``` ## Configuration @@ -67,38 +70,32 @@ Add GitHub to your Operator configuration: [git.github] enabled = true -token_env = "GITHUB_TOKEN" -owner = "your-username" # or organization -repo = "your-repo" +token_env = "GITHUB_TOKEN" # env var for token (used as fallback if gh CLI auth is unavailable) ``` -Set your token: +If `token_env` is set, export the token: ```bash export GITHUB_TOKEN="ghp_xxxxx" ``` -## Pull Request Settings +### Provider Auto-Detection -Configure PR behavior: +Operator auto-detects GitHub from your git remote URL. You can also set it explicitly: ```toml -[git.github.pr] -base_branch = "main" -draft = false -auto_merge = false -reviewers = ["teammate1", "teammate2"] -labels = ["automated", "ai-generated"] +[git] +provider = "github" ``` -## Branch Naming +### Shared Git Settings -Configure branch name format: +Branch naming and worktree settings live under the shared `[git]` section (see [Supported Git Repositories](/getting-started/git/) for details): ```toml -[git.github] -branch_format = "{type}/{ticket_id}-{slug}" -# Example: feat/PROJ-123-add-login +[git] +branch_format = "{type}/{ticket_id}" +use_worktrees = false ``` ## Commit Messages @@ -117,22 +114,20 @@ Ticket: PROJ-123 ### Authentication errors -Test your token: +Check your auth status: ```bash -curl -H "Authorization: token $GITHUB_TOKEN" \ - https://api.github.com/user +gh auth status ``` ### Permission denied -Ensure your token has `repo` scope and you have push access. +Ensure you have push access to the repository and your `gh` session is authenticated. ### Rate limiting -GitHub has API rate limits. Check remaining quota: +Check remaining API quota: ```bash -curl -H "Authorization: token $GITHUB_TOKEN" \ - https://api.github.com/rate_limit +gh api rate_limit ``` diff --git a/docs/getting-started/git/gitlab.md b/docs/getting-started/git/gitlab.md index b0c31df..f3ad902 100644 --- a/docs/getting-started/git/gitlab.md +++ b/docs/getting-started/git/gitlab.md @@ -1,78 +1,131 @@ --- title: "GitLab" -description: "Configure GitLab integration with Operator (Coming Soon)." +description: "Configure GitLab integration with Operator." layout: doc published: true --- # GitLab -GitLab integration is planned for a future release. +Connect Operator to GitLab for repository management and merge requests. -## Planned Features +## Prerequisites -When available, GitLab support will include: +| Requirement | Purpose | Verification | +|------------|---------|--------------| +| `git` | Version control | `git --version` | +| `glab` | GitLab CLI | `glab --version` | +| GitLab account | Repository access | - | +| Push access | Create branches/MRs | - | -- Merge request creation and monitoring -- Review status tracking -- CI/CD pipeline status integration -- Branch management +## Install GitLab CLI -## Expected Prerequisites +The `glab` CLI handles authentication and API operations. Operator uses `glab` directly rather than raw API calls. -GitLab integration will require: +### macOS -| Requirement | Purpose | -|------------|---------| -| GitLab account | Repository access | -| Personal Access Token or Project Token | API authentication | -| `glab` CLI (optional) | Alternative to direct API | +```bash +brew install glab +``` -### Token Scopes (Planned) +### Linux -- `api` - Full API access -- `read_repository` - Read repository contents -- `write_repository` - Push changes +```bash +# Debian/Ubuntu +curl -fsSL https://gitlab.com/gitlab-org/cli/-/releases/permalink/latest/downloads/glab_amd64.deb -o glab.deb +sudo dpkg -i glab.deb -## Planned Configuration +# Fedora/RHEL +sudo dnf install glab +``` + +### Windows + +```powershell +winget install --id GLab.GLab +``` + +## Authenticate + +The `glab` CLI manages authentication, including OAuth flows and credential storage: + +```bash +glab auth login +``` + +Follow the prompts to authenticate via browser or token. Verify with: + +```bash +glab auth status +``` + +## Configuration + +Add GitLab to your Operator configuration: ```toml # ~/.config/operator/config.toml -[git] -provider = "gitlab" - [git.gitlab] enabled = true -token_env = "GITLAB_TOKEN" -host = "gitlab.com" # Or self-hosted instance -project_id = "namespace/project" +token_env = "GITLAB_TOKEN" # env var for token (used as fallback if glab CLI auth is unavailable) +host = "gitlab.com" # or your self-hosted instance (e.g., "gitlab.example.com") ``` -### Merge Request Settings (Planned) +If `token_env` is set, export the token: -```toml -[git.gitlab.mr] -target_branch = "main" -draft = false -remove_source_branch = true -squash = false -reviewers = ["username1", "username2"] -labels = ["automated", "ai-generated"] +```bash +export GITLAB_TOKEN="glpat-xxxxx" ``` -## CLI Alternative +Merge request operations (create, monitor, review tracking) are not yet implemented. Provider detection, configuration, and `glab` CLI authentication work today. -GitLab's official CLI (`glab`) may be used as an alternative: +### Provider Auto-Detection + +Operator auto-detects GitLab from your git remote URL, including self-hosted instances (any URL containing `gitlab.`): ```bash -# Install glab -brew install glab +# All of these are auto-detected as GitLab: +git@gitlab.com:owner/repo.git +https://gitlab.com/owner/repo +https://gitlab.example.com/owner/repo +``` -# Authenticate -glab auth login +You can also set the provider explicitly: + +```toml +[git] +provider = "gitlab" ``` -## Contributing +### Shared Git Settings + +Branch naming and worktree settings live under the shared `[git]` section (see [Supported Git Repositories](/getting-started/git/) for details): + +```toml +[git] +branch_format = "{type}/{ticket_id}" +use_worktrees = false +``` -Interested in helping implement GitLab support? See the [Provider Support](/getting-started/git/provider-support/) guide for architecture details. +## Troubleshooting + +### Authentication errors + +Check your auth status: + +```bash +glab auth status +``` + +### Permission denied + +Ensure you have push access to the repository and your `glab` session is authenticated. + +### Self-hosted connectivity + +If using a self-hosted GitLab instance, verify the `host` value in your config matches the instance hostname and that the instance is reachable: + +```bash +glab auth status --hostname gitlab.example.com +``` diff --git a/docs/getting-started/git/index.md b/docs/getting-started/git/index.md index 1af092e..96220ef 100644 --- a/docs/getting-started/git/index.md +++ b/docs/getting-started/git/index.md @@ -6,7 +6,7 @@ layout: doc # Supported Git Repositories -Operator integrates with Git hosting platforms to manage branches and pull requests. +Operator integrates with Git hosting platforms to manage branches and pull/merge requests. ## Prerequisites @@ -21,6 +21,30 @@ All providers require: | Platform | Status | CLI Tool | Notes | |----------|--------|----------|-------| | [GitHub](/getting-started/git/github/) | Supported | `gh` | Full PR integration | +| [GitLab](/getting-started/git/gitlab/) | Partial | `glab` | Detection and config ready; MR operations planned | + +## Provider Auto-Detection + +Operator detects your Git provider from the remote URL automatically. You can override this in config: + +```toml +[git] +provider = "github" # or "gitlab" +``` + +## Shared Git Configuration + +These settings apply regardless of provider: + +```toml +[git] +branch_format = "{type}/{ticket_id}" # Branch naming pattern +use_worktrees = false # Per-ticket worktree isolation +``` + +**Branch format variables:** `{type}` is the ticket type prefix (e.g., `feature`, `fix`, `spike`, `investigation`), `{ticket_id}` is the ticket identifier. + +**Worktrees:** When enabled, Operator creates isolated git worktrees per ticket, allowing parallel development without branch switching. ## How It Works @@ -29,7 +53,7 @@ When an agent completes work on a ticket: 1. **Branch**: Creates a feature branch from main 2. **Commit**: Commits changes with ticket reference 3. **Push**: Pushes branch to remote -4. **PR**: Opens a pull request for review +4. **PR/MR**: Opens a pull request or merge request for review ## Local Git diff --git a/docs/getting-started/git/provider-support.md b/docs/getting-started/git/provider-support.md index 0190694..57679ad 100644 --- a/docs/getting-started/git/provider-support.md +++ b/docs/getting-started/git/provider-support.md @@ -30,9 +30,12 @@ Operator uses a trait-based architecture for Git provider support: Uses the provider's official CLI tool: -| Provider | CLI Tool | Install | -|----------|----------|---------| -| GitHub | `gh` | `brew install gh` | +| Provider | CLI Tool | Install | Status | +|----------|----------|---------|--------| +| GitHub | `gh` | `brew install gh` | Implemented | +| GitLab | `glab` | `brew install glab` | Detection only | +| Bitbucket | `bb` | — | Detection only | +| Azure DevOps | `az` | — | Detection only | **Advantages:** - Built-in authentication management @@ -181,12 +184,12 @@ async fn test_newprovider_create_pr() { ... } Different providers use different terminology for similar concepts: -| Concept | GitHub | Other Providers | -|---------|--------|-----------------| -| Code Review Request | Pull Request | Merge Request, Pull Request | -| CI Status | Checks | Pipelines, Builds | -| CI Automation | Actions | CI/CD, Pipelines | -| Approval | Review | Approval, Review | +| Concept | GitHub | GitLab | Bitbucket | +|---------|--------|--------|-----------| +| Code Review Request | Pull Request | Merge Request | Pull Request | +| CI Status | Checks | Pipelines | Pipelines | +| CI Automation | Actions | CI/CD | Pipelines | +| Approval | Review | Approval | Approval | ## Provider Detection @@ -194,10 +197,16 @@ Operator auto-detects the provider from git remote URLs: ```rust pub fn detect_provider(remote_url: &str) -> Option { - if remote_url.contains("github.com") { + let url_lower = remote_url.to_lowercase(); + if url_lower.contains("github.com") { Some(GitProvider::GitHub) + } else if url_lower.contains("gitlab.com") || url_lower.contains("gitlab.") { + Some(GitProvider::GitLab) + } else if url_lower.contains("bitbucket.org") { + Some(GitProvider::Bitbucket) + } else if url_lower.contains("dev.azure.com") || url_lower.contains("visualstudio.com") { + Some(GitProvider::AzureDevOps) } else { - // Future providers can be detected here None } } @@ -210,14 +219,21 @@ pub fn detect_provider(remote_url: &str) -> Option { ```toml [git] provider = "github" # Auto-detected if not specified -branch_format = "{type}/{ticket_id}-{slug}" +branch_format = "{type}/{ticket_id}" +use_worktrees = false ``` ### Provider-Specific ```toml [git.github] +enabled = true token_env = "GITHUB_TOKEN" + +[git.gitlab] +enabled = true +token_env = "GITLAB_TOKEN" +host = "gitlab.example.com" # For self-hosted instances ``` ## Testing Guidelines diff --git a/src/rest/routes/llm_tools.rs b/src/rest/routes/llm_tools.rs index 48c5925..6ef1211 100644 --- a/src/rest/routes/llm_tools.rs +++ b/src/rest/routes/llm_tools.rs @@ -27,7 +27,7 @@ pub async fn list(State(state): State) -> Json { #[cfg(test)] mod tests { use super::*; - use crate::config::{Config, DetectedTool}; + use crate::config::{Config, DetectedTool, ToolCapabilities}; use std::path::PathBuf; #[tokio::test] @@ -55,7 +55,7 @@ mod tests { "haiku".to_string(), ], command_template: String::new(), - capabilities: Default::default(), + capabilities: ToolCapabilities::default(), yolo_flags: vec![], }); let state = ApiState::new(config, PathBuf::from("/tmp/test")); diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 98f6196..4928767 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -379,7 +379,8 @@ mod tests { let mut dashboard = make_test_dashboard(); dashboard.status_message = Some("Old message".to_string()); // Set timestamp to 6 seconds ago - dashboard.status_message_at = Some(Instant::now() - Duration::from_secs(6)); + dashboard.status_message_at = + Some(Instant::now().checked_sub(Duration::from_secs(6)).unwrap()); dashboard.clear_expired_status(); assert!(dashboard.status_message.is_none()); From 378add06d06dee8422691ec594eacd941aa7e998 Mon Sep 17 00:00:00 2001 From: untra Date: Mon, 23 Mar 2026 20:36:42 -0600 Subject: [PATCH 6/6] zellij test tightening Co-Authored-By: Claude Opus 4.5 --- .../workflows/integration-tests-matrix.yml | 1 + README.md | 165 ++++++++---------- scripts/ci/run-zellij-integration.sh | 62 +++++-- tests/launch_common/mod.rs | 37 ++-- tests/launch_integration_zellij.rs | 11 ++ .../src/sections/connections-section.ts | 29 ++- 6 files changed, 177 insertions(+), 128 deletions(-) diff --git a/.github/workflows/integration-tests-matrix.yml b/.github/workflows/integration-tests-matrix.yml index 4e44960..98f3496 100644 --- a/.github/workflows/integration-tests-matrix.yml +++ b/.github/workflows/integration-tests-matrix.yml @@ -281,6 +281,7 @@ jobs: run: | zellij delete-all-sessions --yes --force 2>/dev/null || true pkill -f zellij 2>/dev/null || true + rm -rf /tmp/operator-zellij-mock-output # ============================================================================ # CMUX INTEGRATION TESTS (mock-based, macOS) diff --git a/README.md b/README.md index d2dd2e9..beb5d90 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Operator! [![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) [![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/untra.operator-terminals?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=untra.operator-terminals) -**Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) [![VS Code](https://img.shields.io/badge/VS_Code-007ACC?logo=visualstudiocode&logoColor=white)](https://operator.untra.io/getting-started/sessions/vscode/) **|** **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) **|** **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) **|** **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) +**Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) **|** **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) **|** **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) **|** **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development. @@ -21,16 +21,16 @@ and you are ready to start seriously automating your work. ## Overview -`operator` is a TUI (terminal user interface) application that uses session wrappers ([tmux](https://operator.untra.io/getting-started/sessions/tmux/), [cmux](https://operator.untra.io/getting-started/sessions/cmux/), [Zellij](https://operator.untra.io/getting-started/sessions/zellij/), or [VS Code](https://operator.untra.io/getting-started/sessions/vscode/)) to manage multiple AI coding agents across multi-project workspaces of many codebases. It is designed to be ticket-first, starting claude code keyed off from markdown stories from a ticketing provider. It provides: +`operator` is a TUI (terminal user interface) application that uses session wrappers ([tmux](https://operator.untra.io/getting-started/sessions/tmux/), [cmux](https://operator.untra.io/getting-started/sessions/cmux/), or [Zellij](https://operator.untra.io/getting-started/sessions/zellij/)) to manage multiple AI coding agents across multi-project workspaces of many codebases. It is designed to be ticket-first, launching LLM coding agents keyed off from markdown stories from a ticketing provider. It provides: - **Queue Management**: ticket queue with priority-based work assignment, launchable from a dashboard -- **Agent Orchestration**: Launch, monitor, pause/resume Claude Desktop agents against kanban shaped work tickets, and track the ticket progress as it goes through your defined work implementation steps +- **Agent Orchestration**: Launch, monitor, pause/resume LLM coding agents against kanban shaped work tickets, and track the ticket progress as it goes through your defined work implementation steps - **Notifications**: macOS and linux notifications for agent events, keeping you the human in the loop. - **Dashboard**: Real-time view of queue, active agents, completed work, and waiting instances seeking feedback or human review Operator is designed to facilitate work from markdown tickets, tackling tasks across multiple code repositories by semi-autonomous agents. Operator should be started from the root of your collective work projects repository (eg, `~/Documents`), so that it may start feature or fix work in the right part of the codebase. -When started for the first time, Operator will setup configuration to consume and process work tickets, and identify local projects with `claude.md files` to setup. +When started for the first time, Operator will setup configuration to consume and process work tickets, and identify local projects by scanning for LLM tool marker files (`CLAUDE.md`, `CODEX.md`, `GEMINI.md`) and git repositories. Operator comes with a separate web component, unneeded to do work but purpose built to give you a developer portal to expand their workflows and administrate Operator with ease. @@ -49,26 +49,11 @@ operator create # Create a new work ticket operator agents # List active agents operator pause # Pause queue processing operator resume # Resume queue processing -``` - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ operator TUI │ -├─────────────────────────────────────────────────────────────┤ -│ Queue Manager │ Agent Manager │ Notification Svc │ -│ - Watch .tickets │ - Launch agents │ - macOS notifs │ -│ - Priority sort │ - Track status │ - Configurable │ -│ - Work assign │ - Pause/resume │ - Event hooks │ -├─────────────────────────────────────────────────────────────┤ -│ State Store (.operator/) │ -│ - Agent sessions │ - Queue state │ - Config │ -└─────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ - Claude Desktop .tickets/ Third-party - Windows queue/ integrations +operator stalled # Show stalled agents awaiting input +operator alert # Create investigation from external alert +operator docs # Generate documentation from source-of-truth files +operator api # Start the REST API server +operator setup # Initialize operator workspace ``` ## Installation @@ -103,28 +88,44 @@ sudo cp target/release/operator /usr/local/bin/ ## Configuration -Configuration lives in `~/.config/operator/config.toml` or `./config/default.toml`: +Workspace configuration lives in `.tickets/operator/config.toml` (created by `operator setup`). An optional global override can be placed at `~/.config/operator/config.toml`. ```toml [agents] max_parallel = 5 # Maximum concurrent agents -cores_reserved = 1 # Cores to keep free (max = cores - reserved) +cores_reserved = 1 # Cores to keep free (actual max = cores - reserved) +health_check_interval = 30 [notifications] enabled = true -on_agent_start = true -on_agent_complete = true -on_agent_needs_input = true -on_pr_created = true + +[notifications.os] +enabled = true sound = false +events = [] # Empty = all events [queue] auto_assign = true # Automatically assign work when agents free priority_order = ["INV", "FIX", "FEAT", "SPIKE"] +poll_interval_ms = 1000 [paths] -tickets = "../.tickets" -projects = ".." +tickets = ".tickets" # Relative to cwd +projects = "." # cwd is projects root +state = ".tickets/operator" + +[ui] +refresh_rate_ms = 250 +completed_history_hours = 24 +summary_max_length = 40 + +[launch] +confirm_autonomous = true +confirm_paired = true +launch_delay_ms = 2000 + +[sessions] +wrapper = "tmux" # "tmux", "cmux", or "zellij" ``` ## Ticket Priority @@ -177,27 +178,13 @@ Within each priority level, tickets are processed FIFO by timestamp. ## Keyboard Shortcuts -| Key | Action | -|-----|--------| -| `q` | Quit | -| `Q` | Focus queue panel | -| `L` | Launch next ticket (with confirmation) | -| `l` | Launch specific ticket | -| `P` | Pause all processing | -| `R` | Resume processing | -| `A` | Focus agents panel | -| `a` | View agent details | -| `N` | Toggle notifications | -| `?` | Help | -| `↑/↓` | Navigate lists | -| `Enter` | Select/expand | -| `Esc` | Back/cancel | +See [Keyboard Shortcuts Reference](https://operator.untra.io/shortcuts/) for the full list, auto-generated from [`src/ui/keybindings.rs`](src/ui/keybindings.rs). Press `?` in the TUI to view shortcuts in-app. ## Integration Points -### Claude Desktop +### LLM Agent Launch -Agents are launched by opening Claude Desktop with the appropriate project folder and an initial prompt pointing to the ticket. +Agents are launched in terminal sessions with the appropriate project folder and an initial prompt derived from the ticket. ### Third-Party Integrations @@ -211,56 +198,41 @@ Investigations can be triggered externally: operator alert --source pagerduty --message "500 errors in backend" --severity S1 ``` -## LLM CLI Tool Requirements +## LLM CLI Tool Integration -Operator launches LLM agents via CLI tools in tmux sessions. To be compatible with Operator, an LLM CLI tool must support the following capabilities: +Operator launches LLM agents via CLI tools in terminal sessions. Each tool is configured via a JSON definition in `src/llm/tools/`. -### Required CLI Flags +### Supported Tools -| Flag | Purpose | Example | -|------|---------|---------| -| `-p` or `--prompt` | Accept an initial prompt/instruction | `-p "implement feature X"` | -| `--model` | Specify which model to use | `--model opus` | -| `--session-id` | UUID for session continuity/resumption | `--session-id 550e8400-...` | +| Tool | Detection | Models | Session Flag | +|------|-----------|--------|--------------| +| `claude` | `claude --version` | opus, sonnet, haiku | `--session-id` | +| `codex` | `codex --version` | gpt-4o, o1, o3 | `--resume` | +| `gemini` | `gemini --version` | pro, flash, ultra | `--resume` | ### How Operator Calls LLM Tools -Operator constructs commands in this format: - -```bash - --model -p "$(cat )" --session-id -``` +Each tool has a JSON config in `src/llm/tools/` that defines argument mappings and a command template. Operator constructs the launch command from this config: -**Details:** - **Prompt file**: Prompts are written to `.tickets/operator/prompts/.txt` to avoid shell escaping issues with multiline prompts - **Session ID**: A UUID v4 is generated per launch, enabling session resumption - **Model aliases**: Operator uses short aliases (e.g., "opus", "sonnet") that resolve to latest model versions -### Currently Supported Tools - -| Tool | Detection | Models | -|------|-----------|--------| -| `claude` | `which claude` + `claude --version` | opus, sonnet, haiku | - ### Adding Support for New LLM Tools -To add support for a new LLM CLI tool (e.g., OpenAI Codex, Google Gemini): - -1. Create a detector in `src/llm/.rs` that: - - Checks if the binary exists (`which `) - - Gets version (` --version`) - - Returns available model aliases +Create a new JSON tool config following the schema in `src/llm/tools/tool_config.schema.json`. The config defines: -2. Register the detector in `src/llm/detection.rs` - -3. Update the launcher in `src/agents/launcher.rs` to handle the tool's specific CLI syntax +- Tool binary name and version detection command +- Model aliases and argument mappings +- Command template with placeholder variables +- Capability flags (sessions, headless, permission modes) **Requirements for the LLM tool:** - Must be installable as a CLI binary - Must accept prompt via flag (not just stdin) - Must support model selection - Should support session/conversation ID for continuity -- Should run interactively in a terminal (for tmux integration) +- Should run interactively in a terminal (for session wrapper integration) ## Development @@ -281,15 +253,21 @@ Reference documentation is auto-generated from source-of-truth files to minimize ### Available References -| Reference | Location | Source | -|-----------|----------|--------| -| CLI Commands | `docs/cli/` | clap definitions in `src/main.rs` | -| Configuration | `docs/configuration/` | `src/config.rs` via schemars | -| Keyboard Shortcuts | `docs/shortcuts/` | `src/ui/keybindings.rs` | -| REST API (OpenAPI) | `docs/schemas/openapi.json` | utoipa annotations in `src/rest/` | -| Issue Type Schema | `docs/schemas/issuetype.md` | `src/schemas/issuetype_schema.json` | -| Ticket Metadata Schema | `docs/schemas/metadata.md` | `src/schemas/ticket_metadata.schema.json` | -| Project Taxonomy | `docs/backstage/taxonomy.md` | `src/backstage/taxonomy.toml` | +| Generator | Source | Output | +|-----------|--------|--------| +| taxonomy | `src/backstage/taxonomy.toml` | `docs/backstage/taxonomy.md` | +| issuetype-schema | `src/schemas/issuetype_schema.json` | `docs/schemas/issuetype.md` | +| metadata-schema | `src/schemas/ticket_metadata.schema.json` | `docs/schemas/metadata.md` | +| shortcuts | `src/ui/keybindings.rs` | `docs/shortcuts/index.md` | +| cli | `src/main.rs`, `src/env_vars.rs` | `docs/cli/index.md` | +| config | `src/config.rs` | `docs/configuration/index.md` | +| OpenAPI | `src/rest/` (utoipa annotations) | `docs/schemas/openapi.json` | +| llm-tools | `src/llm/tools/tool_config.schema.json` | `docs/llm-tools/index.md` | +| startup | `src/startup/mod.rs` | `docs/startup/index.md` | +| config-schema | `docs/schemas/config.json` | `docs/schemas/config.md` | +| state-schema | `docs/schemas/state.json` | `docs/schemas/state.md` | +| schema-index | `docs/schemas/` | `docs/schemas/index.md` | +| jira-api | `docs/schemas/jira-api.json` | `docs/getting-started/kanban/jira-api.md` | ### Viewing Documentation @@ -311,11 +289,8 @@ cargo run -- docs # Regenerate specific docs cargo run -- docs --only openapi cargo run -- docs --only config -``` -## TODO - -* [ ] `--setup jira` option for jira sync for workspace setup -* [ ] `--setup linear` option for linear kanban provider -* [ ] `--setup gitlab` option for gitlab repository provider -* [ ] `--sync https://foobar.atlassian.net --project ABC` option for jira ticket sync +# Available generators: taxonomy, issuetype-schema, metadata-schema, shortcuts, +# cli, config, OpenAPI, llm-tools, startup, config-schema, state-schema, +# schema-index, jira-api +``` diff --git a/scripts/ci/run-zellij-integration.sh b/scripts/ci/run-zellij-integration.sh index de82b1b..d91d58f 100755 --- a/scripts/ci/run-zellij-integration.sh +++ b/scripts/ci/run-zellij-integration.sh @@ -1,10 +1,14 @@ #!/bin/bash -# Run zellij integration tests. +# Run zellij integration tests inside a real zellij session. # -# Zellij launch tests require the ZELLIJ env var to be set, simulating -# running inside a zellij session. The tests use `require_in_zellij = false` -# in the config, so the launcher will proceed even without a real session, -# but zellij CLI commands are used for tab verification. +# Zellij launch tests require a running zellij server because the operator +# binary calls `zellij action new-tab`, `zellij action write-chars`, etc. +# We use `script -qefc` to allocate a pseudo-TTY (required by zellij on +# headless CI runners) and `zellij run --close-on-exit` to execute the +# tests inside a real session. +# +# MOCK_LLM_OUTPUT_DIR is exported BEFORE starting zellij so the server +# process inherits it, and every new tab the operator creates will too. # # Usage: # scripts/ci/run-zellij-integration.sh @@ -18,25 +22,47 @@ set -euo pipefail echo "=== Zellij Integration Tests ===" echo "Zellij version: $(zellij --version)" -# Set environment variables for the tests -export OPERATOR_LAUNCH_TEST_ENABLED=true -export OPERATOR_ZELLIJ_TEST_ENABLED=true +# Fixed shared output dir for mock LLM invocations. +# Exported before zellij starts so the server (and all tabs) inherit it. +export MOCK_LLM_OUTPUT_DIR="/tmp/operator-zellij-mock-output" +mkdir -p "$MOCK_LLM_OUTPUT_DIR" -# Simulate being inside zellij by setting the env var. -# The test config uses require_in_zellij = false, so the launcher -# checks this env var for session context but doesn't hard-fail. -export ZELLIJ="${ZELLIJ:-0}" -export ZELLIJ_SESSION_NAME="${ZELLIJ_SESSION_NAME:-operator-ci-test}" +# 1. Start a zellij session in the background (with PTY via script). +# `zellij run` requires an already-running session to create a pane in, +# so we must start the session first. +script -qfc "zellij --session operator-ci-test" /dev/null & +ZELLIJ_BG_PID=$! -echo "ZELLIJ=$ZELLIJ" -echo "ZELLIJ_SESSION_NAME=$ZELLIJ_SESSION_NAME" +# 2. Wait for session to be ready +echo "Waiting for zellij session..." +for i in $(seq 1 15); do + if zellij list-sessions 2>/dev/null | grep -q operator-ci-test; then + echo "Session ready after ${i}s" + break + fi + if [ "$i" -eq 15 ]; then + echo "ERROR: Zellij session did not start within 15s" + kill $ZELLIJ_BG_PID 2>/dev/null || true + exit 1 + fi + sleep 1 +done -# Run the tests -cargo test --test launch_integration_zellij -- --nocapture --test-threads=1 +# 3. Run tests inside the real session. +# - `script -qefc` provides a pseudo-TTY for headless CI +# - `-e` propagates the child exit code +# - `zellij run --close-on-exit` returns the pane's exit code +script -qefc "zellij --session operator-ci-test run --close-on-exit -- bash -c ' + export OPERATOR_LAUNCH_TEST_ENABLED=true + export OPERATOR_ZELLIJ_TEST_ENABLED=true + cargo test --test launch_integration_zellij -- --nocapture --test-threads=1 +'" /dev/null EXIT_CODE=$? -# Cleanup any leftover zellij sessions +# 4. Cleanup +kill $ZELLIJ_BG_PID 2>/dev/null || true zellij delete-all-sessions --yes --force 2>/dev/null || true +rm -rf "$MOCK_LLM_OUTPUT_DIR" echo "=== Zellij Integration Tests Complete (exit: $EXIT_CODE) ===" exit $EXIT_CODE diff --git a/tests/launch_common/mod.rs b/tests/launch_common/mod.rs index 4194642..f1f152e 100644 --- a/tests/launch_common/mod.rs +++ b/tests/launch_common/mod.rs @@ -370,16 +370,17 @@ config_generated = false pub fn get_invocations(&self) -> Vec { let mut invocations = Vec::new(); - if let Ok(entries) = fs::read_dir(self.output_dir.path()) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().map(|e| e == "json").unwrap_or(false) { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(inv) = serde_json::from_str(&content) { - invocations.push(inv); - } - } - } + // Primary: per-test output dir (works for tmux where child processes + // inherit the calling process's environment) + collect_json_invocations(self.output_dir.path(), &mut invocations); + + // Fallback: shared output dir from MOCK_LLM_OUTPUT_DIR env var. + // Needed for zellij where new tabs inherit env vars from the zellij + // server process, not from the operator subprocess. + if let Ok(shared_dir) = env::var("MOCK_LLM_OUTPUT_DIR") { + let shared_path = PathBuf::from(&shared_dir); + if shared_path.exists() && shared_path != self.output_dir.path() { + collect_json_invocations(&shared_path, &mut invocations); } } @@ -440,3 +441,19 @@ config_generated = false .unwrap_or(false) } } + +/// Collect mock LLM invocation JSON files from a directory +fn collect_json_invocations(dir: &std::path::Path, invocations: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(inv) = serde_json::from_str(&content) { + invocations.push(inv); + } + } + } + } + } +} diff --git a/tests/launch_integration_zellij.rs b/tests/launch_integration_zellij.rs index 85e0689..19fc0d0 100644 --- a/tests/launch_integration_zellij.rs +++ b/tests/launch_integration_zellij.rs @@ -124,6 +124,17 @@ struct ZellijTestContext { impl ZellijTestContext { fn new(test_name: &str) -> Self { + // Clean shared mock output dir between tests. Zellij tabs inherit + // MOCK_LLM_OUTPUT_DIR from the zellij server process, not from the + // operator subprocess, so all tests share the same output path. + // Tests run sequentially (--test-threads=1) so this is safe. + if let Ok(dir) = env::var("MOCK_LLM_OUTPUT_DIR") { + let path = std::path::Path::new(&dir); + if path.exists() { + let _ = std::fs::remove_dir_all(path); + } + let _ = std::fs::create_dir_all(path); + } Self { ctx: LaunchTestContext::new(test_name, WrapperTestMode::Zellij), } diff --git a/vscode-extension/src/sections/connections-section.ts b/vscode-extension/src/sections/connections-section.ts index cf69ff2..8f93f98 100644 --- a/vscode-extension/src/sections/connections-section.ts +++ b/vscode-extension/src/sections/connections-section.ts @@ -15,6 +15,7 @@ export class ConnectionsSection implements StatusSection { private apiStatus: ApiStatus = { connected: false }; private operatorVersion: string | undefined; private mcpRegistered: boolean = false; + private wrapperType: string = 'vscode'; get isApiConnected(): boolean { return this.apiStatus.connected; @@ -29,6 +30,7 @@ export class ConnectionsSection implements StatusSection { this.checkWebhookStatus(ctx), this.checkApiStatus(ctx), this.checkOperatorVersion(ctx), + this.checkWrapperType(ctx), ]); this.mcpRegistered = isMcpServerRegistered(); } @@ -101,6 +103,20 @@ export class ConnectionsSection implements StatusSection { } } + private async checkWrapperType(ctx: SectionContext): Promise { + try { + const config = await ctx.readConfigToml(); + const sessions = config.sessions as Record | undefined; + if (sessions?.wrapper && typeof sessions.wrapper === 'string') { + this.wrapperType = sessions.wrapper; + } else { + this.wrapperType = 'vscode'; + } + } catch { + this.wrapperType = 'vscode'; + } + } + private async tryHealthCheck(apiUrl: string, sessionVersion?: string): Promise { try { const response = await fetch(`${apiUrl}/api/v1/health`); @@ -141,11 +157,14 @@ export class ConnectionsSection implements StatusSection { const configuredBoth = ctx.configReady; // 1. Session Wrapper + const isVscodeWrapper = this.wrapperType === 'vscode'; const wrapperItem = new StatusItem({ label: 'Session Wrapper', - description: 'VS Code Terminal', - icon: 'operator-vscode', - tooltip: 'Sessions route through the VS Code webhook to managed terminals', + description: isVscodeWrapper ? 'VS Code Terminal' : this.wrapperType, + icon: isVscodeWrapper ? 'pass' : 'warning', + tooltip: isVscodeWrapper + ? 'Sessions route through the VS Code webhook to managed terminals' + : `Sessions use ${this.wrapperType} — VS Code terminal integration unavailable`, sectionId: this.sectionId, }); @@ -209,14 +228,14 @@ export class ConnectionsSection implements StatusSection { ? new StatusItem({ label: 'Webhook', description: `Running${this.webhookStatus.port ? ` :${this.webhookStatus.port}` : ''}`, - icon: 'operator-webhook', + icon: 'pass', tooltip: `Webhook bridge: Operator API \u2192 VS Code terminals (port ${this.webhookStatus.port})`, sectionId: this.sectionId, }) : new StatusItem({ label: 'Webhook', description: configuredBoth ? `Stopped` : 'Not Ready', - icon: 'operator-webhook', + icon: 'circle-slash', tooltip: configuredBoth ? 'Click to start webhook server' : 'Complete configuration first',