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..98f3496 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,177 @@ 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 + rm -rf /tmp/operator-zellij-mock-output + + # ============================================================================ + # 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/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index f80e26d..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: @@ -37,6 +39,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Copy generated types + run: npm run copy-types + + - name: Generate icon font + run: mkdir -p images/icons/dist && npm run generate:icons + - name: Lint run: npm run lint @@ -176,6 +184,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Generate icon font + 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/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..b81221f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,16 +78,81 @@ 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" +# 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" +# 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/README.md b/README.md index d1824ae..beb5d90 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/) **|** **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. 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,16 +21,16 @@ 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/), 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. @@ -48,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 @@ -102,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 @@ -176,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 @@ -210,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 @@ -280,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 @@ -310,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/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..91c5820 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, /** @@ -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/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..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, /** @@ -17,7 +21,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..8ef6fa7 100644 --- a/bindings/LaunchTicketResponse.ts +++ b/bindings/LaunchTicketResponse.ts @@ -21,13 +21,25 @@ 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 context reference (e.g. cmux workspace, zellij session) + */ +session_context_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/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/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..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, /** @@ -14,6 +18,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..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 @@ -62,6 +71,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/_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/_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 +149,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 +166,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 +195,7 @@ export class ConfigPanel { ); } - this._panel.webview.postMessage({ + void this._panel.webview.postMessage({ type: 'jiraValidationResult', result: { valid: result.valid, @@ -202,7 +213,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 +240,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 +265,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 +279,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 +297,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 +322,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 +333,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 +491,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 +513,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 +647,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 +662,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 +695,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 +703,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..9118d17 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,13 @@ import { detectLlmTools, openWalkthrough, startKanbanOnboarding, + initializeTicketsDirectory, } from './walkthrough'; import { addJiraProject, addLinearTeam } from './kanban-onboarding'; +import { startGitOnboarding, onboardGitHub, onboardGitLab } from './git-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 +53,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') { @@ -65,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; @@ -113,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); @@ -179,6 +196,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) @@ -195,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) @@ -218,6 +298,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 +351,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 +392,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 +410,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 +435,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 +494,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 +506,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,9 +523,15 @@ async function startServer(): Promise { } if (webhookServer.isRunning()) { - vscode.window.showInformationMessage( - 'Operator webhook server already running' + // 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( + `Webhook connected on port ${webhookServer.getPort()}` ); + await refreshAllProviders(); return; } @@ -470,19 +546,20 @@ 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}` ); } updateStatusBar(); + await refreshAllProviders(); } 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 +583,7 @@ function showStatus(): void { message = 'Operator server stopped'; } - vscode.window.showInformationMessage(message); + void vscode.window.showInformationMessage(message); } /** @@ -544,7 +621,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); @@ -555,6 +632,7 @@ async function launchTicketCommand(treeItem?: TicketItem): Promise { } await launchManager.launchTicket(ticket, { + delegator: null, model: 'sonnet', yoloMode: false, resumeSession: false, @@ -579,7 +657,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 +705,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 +719,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 +729,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; @@ -660,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', @@ -669,15 +748,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 +764,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 +774,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 +788,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 +817,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; @@ -747,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, @@ -756,15 +836,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 +852,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 +895,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 +908,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 +928,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 +964,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 +972,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 +982,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 +1013,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 +1021,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 +1038,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 +1046,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 +1063,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 +1073,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 +1095,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 +1111,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 +1128,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 +1160,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 +1180,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 +1197,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 +1214,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 +1227,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 +1236,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 +1249,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 +1281,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; } @@ -1209,10 +1289,55 @@ 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 */ export function deactivate(): void { - webhookServer?.stop(); + void webhookServer?.stop(); terminalManager?.dispose(); } 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 bede7f2..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', @@ -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..6cd0c0f 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,24 @@ 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.'); + 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; } @@ -943,16 +962,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 +992,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 +1002,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 +1035,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 +1049,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 +1081,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..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'), }; @@ -93,7 +162,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; } @@ -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 8cb07b5..8e0e9ae 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}`); } } @@ -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, @@ -151,17 +152,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}` ); } @@ -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 new file mode 100644 index 0000000..bcbefd8 --- /dev/null +++ b/vscode-extension/src/mcp-connect.ts @@ -0,0 +1,113 @@ +/** + * MCP connection logic for Operator VS Code extension. + * + * Discovers the local Operator API, fetches the MCP descriptor, + * and registers the Operator MCP server in VS Code workspace settings. + */ + +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; +} + +/** + * Check whether an MCP server named "operator" is already registered + * in VS Code workspace settings. + */ +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, + * and writes the server config into VS Code workspace settings + * under the `mcp.servers` key. + */ +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. Write MCP server config to workspace settings + const mcpConfig = vscode.workspace.getConfiguration('mcp'); + const servers = mcpConfig.get>('servers') || {}; + + 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'; + 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/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..8f93f98 --- /dev/null +++ b/vscode-extension/src/sections/connections-section.ts @@ -0,0 +1,307 @@ +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; + private wrapperType: string = 'vscode'; + + 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.checkWrapperType(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 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`); + 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 isVscodeWrapper = this.wrapperType === 'vscode'; + const wrapperItem = new StatusItem({ + label: 'Session Wrapper', + 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, + }); + + // 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: '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: 'circle-slash', + 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 e403dde..c4d6f37 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -1,104 +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 { 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 */ @@ -110,23 +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 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 { @@ -136,15 +100,18 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { async refresh(): Promise { this.parsedConfig = null; + const ctx = this.buildContext(); + + // All sections run check() regardless of visibility + await Promise.allSettled(this.allSections.map(s => s.check(ctx))); - await Promise.all([ - this.checkConfigState(), - this.checkKanbanState(), - this.checkLlmState(), - this.checkGitState(), - this.checkWebhookStatus(), - this.checkApiStatus(), - ]); + // 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); } @@ -178,245 +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 || '', - }; - } - - /** - * Check Kanban section state from config.toml, falling back to env vars - */ - 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: [], - }); - } - } - - this.kanbanState = { - configured: providers.length > 0, - providers, - }; - } - - /** - * Check LLM Tools section state - */ - private async checkLlmState(): Promise { - const tools = await detectInstalledLlmTools(); - - // 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 }; - } - ) - : []; - - this.llmState = { - detected: tools.length > 0 || configDetected.length > 0, - tools, - configDetected, + 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 Git section state + * Build the list of sections visible based on current readiness flags. */ - 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: SessionInfo = JSON.parse(content); + private getVisibleSections(): StatusSection[] { + const visible: StatusSection[] = [this.configSection]; - this.webhookStatus = { - running: true, - version: session.version, - port: session.port, - workspace: session.workspace, - sessionFile: webhookSessionFile, - }; - } catch { - this.webhookStatus = { running: false }; - } - } + // Tier 1: requires config ready + if (!this.ctx.configReady) { return visible; } + visible.push(this.connectionsSection); - /** - * 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: ApiSessionInfo = JSON.parse(content); - const apiUrl = `http://localhost:${session.port}`; + // Tier 2: requires connections ready (API or webhook) + if (!this.ctx.connectionsReady) { return visible; } + visible.push(this.kanbanSection, this.llmSection, this.gitSection); - if (await this.tryHealthCheck(apiUrl, session.version)) { - return; - } - } catch { - // Fall through - } - } + // 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); } - const apiUrl = await discoverApiUrl(this.ticketsDir); - await this.tryHealthCheck(apiUrl); - } - - /** - * 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 { @@ -425,532 +187,15 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { getChildren(element?: StatusItem): StatusItem[] { if (!element) { - return this.getTopLevelSections(); - } - - 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(); + return this.getVisibleSections().map(s => s.getTopLevelItem(this.ctx)); } - // 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; - - 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: configuredBoth ? undefined : { - command: 'operator.selectWorkingDirectory', - title: 'Select Working Directory', - }, - }), - // 2. Connections - new StatusItem({ - label: 'Connections', - description: this.getConnectionsSummary(), - icon: this.getConnectionsIcon(), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - sectionId: 'connections', - }), - - // 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 hasProjects = prov.projects.length > 0; - items.push(new StatusItem({ - label: providerLabel, - description: prov.displayName, - icon: 'cloud', - tooltip: prov.url, - collapsibleState: hasProjects - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None, - 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: 'cloud', - command: { - command: 'operator.configureJira', - title: 'Configure Jira', - }, - })); - items.push(new StatusItem({ - label: 'Configure Linear', - icon: 'cloud', - 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: 'package', - 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 Team'; - 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); - items.push(new StatusItem({ - label: tool.name, - description: tool.version, - icon: 'terminal', - })); - } - - // Show PATH-detected tools not already in config - for (const tool of this.llmState.tools) { - if (!shown.has(tool.name)) { - items.push(new StatusItem({ - label: tool.name, - description: tool.version !== 'unknown' ? tool.version : undefined, - icon: 'terminal', - })); - } - } - - // 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 items: StatusItem[] = []; - - // 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', - })); - } - } 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.', - })); - } - - // 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(), - icon: 'plug', - })); - } - } else { - items.push(new StatusItem({ - label: 'Webhook', - description: 'Stopped', - icon: 'circle-slash', - tooltip: 'Local webhook server not running', - })); - } - - return items; - } - - // ─── 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/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..405ce18 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); @@ -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 */ @@ -199,7 +207,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 +220,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 +232,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 +244,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 +256,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 +321,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..85ce743 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,15 +207,16 @@ 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 }) ); const options: LaunchTicketRequest = { + delegator: null, provider: 'claude', model: 'sonnet', yolo_mode: true, @@ -208,7 +229,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 +237,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); @@ -249,6 +270,7 @@ suite('API Client Test Suite', () => { ); const options: LaunchTicketRequest = { + delegator: null, provider: null, model: null, yolo_mode: false, @@ -259,7 +281,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'); }); @@ -277,6 +299,7 @@ suite('API Client Test Suite', () => { ); const options: LaunchTicketRequest = { + delegator: null, provider: null, model: null, yolo_mode: false, @@ -299,6 +322,7 @@ suite('API Client Test Suite', () => { ); const options: LaunchTicketRequest = { + delegator: null, provider: null, model: null, yolo_mode: false, @@ -342,7 +366,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 +376,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 +387,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 +416,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 +430,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 +459,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 +470,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 +504,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 +518,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 +545,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 +573,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 +590,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 +598,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 +621,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..a085901 --- /dev/null +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -0,0 +1,118 @@ +/** + * Tests for mcp-connect.ts + * + * Tests MCP descriptor fetching and server registration check. + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { + fetchMcpDescriptor, + McpDescriptorResponse, +} from '../../src/mcp-connect'; + +// Path to fixtures +const fixturesDir = path.join( + __dirname, + '..', + '..', + '..', + 'test', + 'fixtures', + 'api' +); + +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' + ); + }); + }); +}); 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/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/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. 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;