From 3a478dcd3fe985d91f92e04fc6226dd2875c8ba9 Mon Sep 17 00:00:00 2001 From: Plum Hill Date: Fri, 3 Apr 2026 22:55:02 -0400 Subject: [PATCH 1/2] Added claude code re-written in rust --- claude-code-rust/.cargo/audit.toml | 12 + claude-code-rust/.gitattributes | 12 + claude-code-rust/.github/CODEOWNERS | 18 + .../.github/ISSUE_TEMPLATE/bug_report.yml | 102 + .../.github/ISSUE_TEMPLATE/config.yml | 8 + .../ISSUE_TEMPLATE/feature_request.yml | 56 + claude-code-rust/.github/dependabot.yml | 27 + .../.github/pull_request_template.md | 26 + claude-code-rust/.github/workflows/audit.yml | 15 + claude-code-rust/.github/workflows/ci.yml | 70 + .../.github/workflows/commit-lint.yml | 27 + .../.github/workflows/release-dryrun.yml | 51 + .../.github/workflows/release-npm.yml | 152 + claude-code-rust/.gitignore | 46 + claude-code-rust/CHANGELOG.md | 443 ++ claude-code-rust/CODE_OF_CONDUCT.md | 90 + claude-code-rust/CONTRIBUTING.md | 95 + claude-code-rust/Cargo.lock | 4759 +++++++++++++++++ claude-code-rust/Cargo.toml | 104 + claude-code-rust/LICENSE | 202 + claude-code-rust/README.md | 75 + claude-code-rust/SECURITY.md | 40 + claude-code-rust/agent-sdk/README.md | 17 + claude-code-rust/agent-sdk/package-lock.json | 347 ++ claude-code-rust/agent-sdk/package.json | 22 + claude-code-rust/agent-sdk/src/bridge.test.ts | 1709 ++++++ claude-code-rust/agent-sdk/src/bridge.ts | 502 ++ .../agent-sdk/src/bridge/agents.ts | 85 + claude-code-rust/agent-sdk/src/bridge/auth.ts | 10 + .../agent-sdk/src/bridge/cache_policy.ts | 16 + .../agent-sdk/src/bridge/commands.ts | 455 ++ .../src/bridge/error_classification.ts | 76 + .../agent-sdk/src/bridge/events.ts | 123 + .../agent-sdk/src/bridge/history.ts | 164 + claude-code-rust/agent-sdk/src/bridge/mcp.ts | 427 ++ .../agent-sdk/src/bridge/message_handlers.ts | 501 ++ .../agent-sdk/src/bridge/permissions.ts | 144 + .../agent-sdk/src/bridge/session_lifecycle.ts | 703 +++ .../agent-sdk/src/bridge/shared.ts | 62 + .../agent-sdk/src/bridge/state_parsing.ts | 84 + .../agent-sdk/src/bridge/tool_calls.ts | 214 + .../agent-sdk/src/bridge/tooling.ts | 675 +++ .../agent-sdk/src/bridge/user_interaction.ts | 310 ++ claude-code-rust/agent-sdk/src/types.ts | 523 ++ claude-code-rust/agent-sdk/tsconfig.json | 17 + claude-code-rust/bin/claude-rs.js | 56 + claude-code-rust/clippy.toml | 19 + claude-code-rust/package-lock.json | 360 ++ claude-code-rust/package.json | 44 + claude-code-rust/rustfmt.toml | 8 + claude-code-rust/scripts/postinstall.js | 92 + claude-code-rust/src/agent/bridge.rs | 91 + claude-code-rust/src/agent/client.rs | 426 ++ claude-code-rust/src/agent/error_handling.rs | 262 + claude-code-rust/src/agent/events.rs | 136 + claude-code-rust/src/agent/mod.rs | 10 + claude-code-rust/src/agent/model.rs | 1050 ++++ claude-code-rust/src/agent/types.rs | 521 ++ claude-code-rust/src/agent/wire.rs | 290 + claude-code-rust/src/app/auth.rs | 128 + claude-code-rust/src/app/cache_policy.rs | 176 + claude-code-rust/src/app/config/edit.rs | 899 ++++ claude-code-rust/src/app/config/mcp.rs | 477 ++ claude-code-rust/src/app/config/mcp_edit.rs | 317 ++ claude-code-rust/src/app/config/mod.rs | 1597 ++++++ claude-code-rust/src/app/config/resolve.rs | 164 + claude-code-rust/src/app/config/store.rs | 810 +++ claude-code-rust/src/app/config/tests.rs | 1464 +++++ .../src/app/connect/bridge_lifecycle.rs | 270 + .../src/app/connect/event_dispatch.rs | 333 ++ claude-code-rust/src/app/connect/mod.rs | 277 + .../src/app/connect/session_start.rs | 253 + .../src/app/connect/type_converters.rs | 764 +++ claude-code-rust/src/app/dialog.rs | 97 + claude-code-rust/src/app/events/client.rs | 186 + claude-code-rust/src/app/events/mod.rs | 4048 ++++++++++++++ claude-code-rust/src/app/events/mouse.rs | 225 + claude-code-rust/src/app/events/rate_limit.rs | 129 + claude-code-rust/src/app/events/session.rs | 310 ++ .../src/app/events/session_reset.rs | 182 + claude-code-rust/src/app/events/streaming.rs | 120 + claude-code-rust/src/app/events/tool_calls.rs | 376 ++ .../src/app/events/tool_updates.rs | 487 ++ claude-code-rust/src/app/events/turn.rs | 418 ++ claude-code-rust/src/app/focus.rs | 149 + .../src/app/inline_interactions.rs | 198 + claude-code-rust/src/app/input.rs | 1616 ++++++ claude-code-rust/src/app/input_submit.rs | 341 ++ claude-code-rust/src/app/keys.rs | 956 ++++ claude-code-rust/src/app/mention.rs | 822 +++ claude-code-rust/src/app/mod.rs | 784 +++ claude-code-rust/src/app/notify.rs | 384 ++ claude-code-rust/src/app/paste_burst.rs | 677 +++ claude-code-rust/src/app/permissions.rs | 748 +++ claude-code-rust/src/app/plugins/cli.rs | 319 ++ claude-code-rust/src/app/plugins/mod.rs | 1383 +++++ claude-code-rust/src/app/questions.rs | 635 +++ claude-code-rust/src/app/selection.rs | 492 ++ .../src/app/service_status_check.rs | 226 + claude-code-rust/src/app/slash/candidates.rs | 347 ++ claude-code-rust/src/app/slash/executors.rs | 450 ++ claude-code-rust/src/app/slash/mod.rs | 786 +++ claude-code-rust/src/app/slash/navigation.rs | 171 + claude-code-rust/src/app/state/block_cache.rs | 235 + .../src/app/state/cache_metrics.rs | 530 ++ .../src/app/state/history_retention.rs | 637 +++ claude-code-rust/src/app/state/messages.rs | 303 ++ claude-code-rust/src/app/state/mod.rs | 3084 +++++++++++ .../src/app/state/render_budget.rs | 311 ++ .../src/app/state/tool_call_info.rs | 195 + claude-code-rust/src/app/state/types.rs | 261 + claude-code-rust/src/app/state/viewport.rs | 829 +++ claude-code-rust/src/app/subagent.rs | 353 ++ claude-code-rust/src/app/terminal.rs | 212 + claude-code-rust/src/app/todos.rs | 441 ++ claude-code-rust/src/app/trust/mod.rs | 220 + claude-code-rust/src/app/trust/store.rs | 470 ++ claude-code-rust/src/app/update_check.rs | 229 + claude-code-rust/src/app/usage/cli.rs | 305 ++ claude-code-rust/src/app/usage/mod.rs | 228 + claude-code-rust/src/app/usage/oauth.rs | 346 ++ claude-code-rust/src/app/view.rs | 50 + claude-code-rust/src/app/view/tests.rs | 151 + claude-code-rust/src/error.rs | 52 + claude-code-rust/src/lib.rs | 52 + claude-code-rust/src/main.rs | 132 + claude-code-rust/src/perf.rs | 279 + claude-code-rust/src/ui/autocomplete.rs | 556 ++ claude-code-rust/src/ui/chat.rs | 1518 ++++++ claude-code-rust/src/ui/chat_view.rs | 113 + claude-code-rust/src/ui/config.rs | 1992 +++++++ claude-code-rust/src/ui/config/input.rs | 70 + claude-code-rust/src/ui/config/mcp.rs | 796 +++ claude-code-rust/src/ui/config/overlay.rs | 143 + claude-code-rust/src/ui/config/plugins.rs | 492 ++ claude-code-rust/src/ui/config/settings.rs | 324 ++ claude-code-rust/src/ui/config/status.rs | 299 ++ claude-code-rust/src/ui/config/usage.rs | 373 ++ claude-code-rust/src/ui/diff.rs | 453 ++ claude-code-rust/src/ui/footer.rs | 601 +++ claude-code-rust/src/ui/help.rs | 898 ++++ claude-code-rust/src/ui/highlight.rs | 218 + claude-code-rust/src/ui/input.rs | 413 ++ claude-code-rust/src/ui/layout.rs | 386 ++ claude-code-rust/src/ui/markdown.rs | 102 + claude-code-rust/src/ui/message.rs | 1676 ++++++ claude-code-rust/src/ui/mod.rs | 50 + claude-code-rust/src/ui/tables.rs | 789 +++ claude-code-rust/src/ui/theme.rs | 65 + claude-code-rust/src/ui/todo.rs | 168 + claude-code-rust/src/ui/tool_call/errors.rs | 116 + claude-code-rust/src/ui/tool_call/execute.rs | 143 + .../src/ui/tool_call/interactions.rs | 484 ++ claude-code-rust/src/ui/tool_call/mod.rs | 855 +++ claude-code-rust/src/ui/tool_call/standard.rs | 334 ++ claude-code-rust/src/ui/trusted.rs | 182 + claude-code-rust/tests/config_flow.rs | 77 + .../tests/integration/caching_pipeline.rs | 498 ++ claude-code-rust/tests/integration/helpers.rs | 13 + .../tests/integration/internal_failures.rs | 139 + claude-code-rust/tests/integration/main.rs | 7 + .../tests/integration/permissions.rs | 266 + .../tests/integration/state_transitions.rs | 550 ++ .../tests/integration/tool_lifecycle.rs | 453 ++ 164 files changed, 69489 insertions(+) create mode 100644 claude-code-rust/.cargo/audit.toml create mode 100644 claude-code-rust/.gitattributes create mode 100644 claude-code-rust/.github/CODEOWNERS create mode 100644 claude-code-rust/.github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 claude-code-rust/.github/ISSUE_TEMPLATE/config.yml create mode 100644 claude-code-rust/.github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 claude-code-rust/.github/dependabot.yml create mode 100644 claude-code-rust/.github/pull_request_template.md create mode 100644 claude-code-rust/.github/workflows/audit.yml create mode 100644 claude-code-rust/.github/workflows/ci.yml create mode 100644 claude-code-rust/.github/workflows/commit-lint.yml create mode 100644 claude-code-rust/.github/workflows/release-dryrun.yml create mode 100644 claude-code-rust/.github/workflows/release-npm.yml create mode 100644 claude-code-rust/.gitignore create mode 100644 claude-code-rust/CHANGELOG.md create mode 100644 claude-code-rust/CODE_OF_CONDUCT.md create mode 100644 claude-code-rust/CONTRIBUTING.md create mode 100644 claude-code-rust/Cargo.lock create mode 100644 claude-code-rust/Cargo.toml create mode 100644 claude-code-rust/LICENSE create mode 100644 claude-code-rust/README.md create mode 100644 claude-code-rust/SECURITY.md create mode 100644 claude-code-rust/agent-sdk/README.md create mode 100644 claude-code-rust/agent-sdk/package-lock.json create mode 100644 claude-code-rust/agent-sdk/package.json create mode 100644 claude-code-rust/agent-sdk/src/bridge.test.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/agents.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/auth.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/cache_policy.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/commands.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/error_classification.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/events.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/history.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/mcp.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/message_handlers.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/permissions.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/session_lifecycle.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/shared.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/state_parsing.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/tool_calls.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/tooling.ts create mode 100644 claude-code-rust/agent-sdk/src/bridge/user_interaction.ts create mode 100644 claude-code-rust/agent-sdk/src/types.ts create mode 100644 claude-code-rust/agent-sdk/tsconfig.json create mode 100644 claude-code-rust/bin/claude-rs.js create mode 100644 claude-code-rust/clippy.toml create mode 100644 claude-code-rust/package-lock.json create mode 100644 claude-code-rust/package.json create mode 100644 claude-code-rust/rustfmt.toml create mode 100644 claude-code-rust/scripts/postinstall.js create mode 100644 claude-code-rust/src/agent/bridge.rs create mode 100644 claude-code-rust/src/agent/client.rs create mode 100644 claude-code-rust/src/agent/error_handling.rs create mode 100644 claude-code-rust/src/agent/events.rs create mode 100644 claude-code-rust/src/agent/mod.rs create mode 100644 claude-code-rust/src/agent/model.rs create mode 100644 claude-code-rust/src/agent/types.rs create mode 100644 claude-code-rust/src/agent/wire.rs create mode 100644 claude-code-rust/src/app/auth.rs create mode 100644 claude-code-rust/src/app/cache_policy.rs create mode 100644 claude-code-rust/src/app/config/edit.rs create mode 100644 claude-code-rust/src/app/config/mcp.rs create mode 100644 claude-code-rust/src/app/config/mcp_edit.rs create mode 100644 claude-code-rust/src/app/config/mod.rs create mode 100644 claude-code-rust/src/app/config/resolve.rs create mode 100644 claude-code-rust/src/app/config/store.rs create mode 100644 claude-code-rust/src/app/config/tests.rs create mode 100644 claude-code-rust/src/app/connect/bridge_lifecycle.rs create mode 100644 claude-code-rust/src/app/connect/event_dispatch.rs create mode 100644 claude-code-rust/src/app/connect/mod.rs create mode 100644 claude-code-rust/src/app/connect/session_start.rs create mode 100644 claude-code-rust/src/app/connect/type_converters.rs create mode 100644 claude-code-rust/src/app/dialog.rs create mode 100644 claude-code-rust/src/app/events/client.rs create mode 100644 claude-code-rust/src/app/events/mod.rs create mode 100644 claude-code-rust/src/app/events/mouse.rs create mode 100644 claude-code-rust/src/app/events/rate_limit.rs create mode 100644 claude-code-rust/src/app/events/session.rs create mode 100644 claude-code-rust/src/app/events/session_reset.rs create mode 100644 claude-code-rust/src/app/events/streaming.rs create mode 100644 claude-code-rust/src/app/events/tool_calls.rs create mode 100644 claude-code-rust/src/app/events/tool_updates.rs create mode 100644 claude-code-rust/src/app/events/turn.rs create mode 100644 claude-code-rust/src/app/focus.rs create mode 100644 claude-code-rust/src/app/inline_interactions.rs create mode 100644 claude-code-rust/src/app/input.rs create mode 100644 claude-code-rust/src/app/input_submit.rs create mode 100644 claude-code-rust/src/app/keys.rs create mode 100644 claude-code-rust/src/app/mention.rs create mode 100644 claude-code-rust/src/app/mod.rs create mode 100644 claude-code-rust/src/app/notify.rs create mode 100644 claude-code-rust/src/app/paste_burst.rs create mode 100644 claude-code-rust/src/app/permissions.rs create mode 100644 claude-code-rust/src/app/plugins/cli.rs create mode 100644 claude-code-rust/src/app/plugins/mod.rs create mode 100644 claude-code-rust/src/app/questions.rs create mode 100644 claude-code-rust/src/app/selection.rs create mode 100644 claude-code-rust/src/app/service_status_check.rs create mode 100644 claude-code-rust/src/app/slash/candidates.rs create mode 100644 claude-code-rust/src/app/slash/executors.rs create mode 100644 claude-code-rust/src/app/slash/mod.rs create mode 100644 claude-code-rust/src/app/slash/navigation.rs create mode 100644 claude-code-rust/src/app/state/block_cache.rs create mode 100644 claude-code-rust/src/app/state/cache_metrics.rs create mode 100644 claude-code-rust/src/app/state/history_retention.rs create mode 100644 claude-code-rust/src/app/state/messages.rs create mode 100644 claude-code-rust/src/app/state/mod.rs create mode 100644 claude-code-rust/src/app/state/render_budget.rs create mode 100644 claude-code-rust/src/app/state/tool_call_info.rs create mode 100644 claude-code-rust/src/app/state/types.rs create mode 100644 claude-code-rust/src/app/state/viewport.rs create mode 100644 claude-code-rust/src/app/subagent.rs create mode 100644 claude-code-rust/src/app/terminal.rs create mode 100644 claude-code-rust/src/app/todos.rs create mode 100644 claude-code-rust/src/app/trust/mod.rs create mode 100644 claude-code-rust/src/app/trust/store.rs create mode 100644 claude-code-rust/src/app/update_check.rs create mode 100644 claude-code-rust/src/app/usage/cli.rs create mode 100644 claude-code-rust/src/app/usage/mod.rs create mode 100644 claude-code-rust/src/app/usage/oauth.rs create mode 100644 claude-code-rust/src/app/view.rs create mode 100644 claude-code-rust/src/app/view/tests.rs create mode 100644 claude-code-rust/src/error.rs create mode 100644 claude-code-rust/src/lib.rs create mode 100644 claude-code-rust/src/main.rs create mode 100644 claude-code-rust/src/perf.rs create mode 100644 claude-code-rust/src/ui/autocomplete.rs create mode 100644 claude-code-rust/src/ui/chat.rs create mode 100644 claude-code-rust/src/ui/chat_view.rs create mode 100644 claude-code-rust/src/ui/config.rs create mode 100644 claude-code-rust/src/ui/config/input.rs create mode 100644 claude-code-rust/src/ui/config/mcp.rs create mode 100644 claude-code-rust/src/ui/config/overlay.rs create mode 100644 claude-code-rust/src/ui/config/plugins.rs create mode 100644 claude-code-rust/src/ui/config/settings.rs create mode 100644 claude-code-rust/src/ui/config/status.rs create mode 100644 claude-code-rust/src/ui/config/usage.rs create mode 100644 claude-code-rust/src/ui/diff.rs create mode 100644 claude-code-rust/src/ui/footer.rs create mode 100644 claude-code-rust/src/ui/help.rs create mode 100644 claude-code-rust/src/ui/highlight.rs create mode 100644 claude-code-rust/src/ui/input.rs create mode 100644 claude-code-rust/src/ui/layout.rs create mode 100644 claude-code-rust/src/ui/markdown.rs create mode 100644 claude-code-rust/src/ui/message.rs create mode 100644 claude-code-rust/src/ui/mod.rs create mode 100644 claude-code-rust/src/ui/tables.rs create mode 100644 claude-code-rust/src/ui/theme.rs create mode 100644 claude-code-rust/src/ui/todo.rs create mode 100644 claude-code-rust/src/ui/tool_call/errors.rs create mode 100644 claude-code-rust/src/ui/tool_call/execute.rs create mode 100644 claude-code-rust/src/ui/tool_call/interactions.rs create mode 100644 claude-code-rust/src/ui/tool_call/mod.rs create mode 100644 claude-code-rust/src/ui/tool_call/standard.rs create mode 100644 claude-code-rust/src/ui/trusted.rs create mode 100644 claude-code-rust/tests/config_flow.rs create mode 100644 claude-code-rust/tests/integration/caching_pipeline.rs create mode 100644 claude-code-rust/tests/integration/helpers.rs create mode 100644 claude-code-rust/tests/integration/internal_failures.rs create mode 100644 claude-code-rust/tests/integration/main.rs create mode 100644 claude-code-rust/tests/integration/permissions.rs create mode 100644 claude-code-rust/tests/integration/state_transitions.rs create mode 100644 claude-code-rust/tests/integration/tool_lifecycle.rs diff --git a/claude-code-rust/.cargo/audit.toml b/claude-code-rust/.cargo/audit.toml new file mode 100644 index 0000000..e137a73 --- /dev/null +++ b/claude-code-rust/.cargo/audit.toml @@ -0,0 +1,12 @@ +[advisories] +ignore = [ + # bincode unmaintained -- transitive dep via tui-markdown -> syntect v5.x + # Upstream fix planned for syntect v6.0.0 (no release date yet) + # Tracked: https://github.com/trishume/syntect/issues/606 + "RUSTSEC-2025-0141", + + # yaml-rust unmaintained -- transitive dep via tui-markdown -> syntect v5.x + # Upstream fix merged in syntect main (PR #608, Feb 2026), waiting on release + # Tracked: https://github.com/trishume/syntect/pull/608 + "RUSTSEC-2024-0320", +] diff --git a/claude-code-rust/.gitattributes b/claude-code-rust/.gitattributes new file mode 100644 index 0000000..db487c0 --- /dev/null +++ b/claude-code-rust/.gitattributes @@ -0,0 +1,12 @@ +* text=auto + +*.rs text eol=lf +*.toml text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +Cargo.lock text eol=lf + +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/claude-code-rust/.github/CODEOWNERS b/claude-code-rust/.github/CODEOWNERS new file mode 100644 index 0000000..0c4587b --- /dev/null +++ b/claude-code-rust/.github/CODEOWNERS @@ -0,0 +1,18 @@ +# Default owner for everything +* @srothgan + +# ACP protocol layer +/src/acp/ @srothgan + +# TUI / interface +/src/ui/ @srothgan + +# Terminal management +/src/terminal/ @srothgan + +# CI/CD configuration +/.github/ @srothgan + +# Documentation +/*.md @srothgan +/docs/ @srothgan diff --git a/claude-code-rust/.github/ISSUE_TEMPLATE/bug_report.yml b/claude-code-rust/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..2250a44 --- /dev/null +++ b/claude-code-rust/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,102 @@ +name: Bug Report +description: Report a bug or unexpected behavior +title: "[Bug]: " +labels: ["type: bug", "status: triage"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug! Please fill out the sections below. + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the bug + placeholder: What happened? + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Minimal steps to reproduce the behavior + placeholder: | + 1. Run `claude-rs` with `--flag` + 2. Type "..." + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened (include error messages, screenshots) + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Output of `claude-rs --version` + placeholder: "claude-rs 0.3.0" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - Linux (x86_64) + - Linux (aarch64) + - macOS (Intel) + - macOS (Apple Silicon) + - Windows (x86_64) + - Windows (ARM64) + - Other + validations: + required: true + + - type: input + id: rust-version + attributes: + label: Rust Version + description: Output of `rustc --version` + placeholder: "rustc 1.86.0 (a1b2c3d4 2025-03-01)" + + - type: textarea + id: logs + attributes: + label: Relevant Log Output + description: Paste any relevant log output (run with `RUST_LOG=debug`) + render: shell + + - type: textarea + id: implementation-notes + attributes: + label: Implementation Notes (optional) + description: Maintainer notes, suspected root cause, or technical pointers + placeholder: | + - Suspected module/path: + - Possible root cause: + - Constraints: + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this is not a duplicate + required: true diff --git a/claude-code-rust/.github/ISSUE_TEMPLATE/config.yml b/claude-code-rust/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9afb266 --- /dev/null +++ b/claude-code-rust/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & Discussion + url: https://github.com/srothgan/claude-code-rust/discussions + about: Use GitHub Discussions for questions, help, and general conversation + - name: Security Vulnerability + url: https://github.com/srothgan/claude-code-rust/security/advisories/new + about: Report security vulnerabilities privately via GitHub Security Advisories diff --git a/claude-code-rust/.github/ISSUE_TEMPLATE/feature_request.yml b/claude-code-rust/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..33c59ca --- /dev/null +++ b/claude-code-rust/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,56 @@ +name: Feature Request +description: Suggest a new feature or improvement +title: "[Feature]: " +labels: ["type: enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement! Please describe your idea. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: What problem does this feature solve? Is it related to a frustration? + placeholder: "I'm always frustrated when..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Any alternative solutions or features you've considered + + - type: textarea + id: implementation-notes + attributes: + label: Implementation Notes (optional) + description: Maintainer notes, technical direction, constraints, or rollout plan + + - type: textarea + id: dependencies + attributes: + label: Dependencies / Blockers (optional) + description: Related issues, upstream blockers, or required sequencing + placeholder: | + - Blocked by #... + - Depends on #... + - Must ship after #... + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues and discussions to ensure this is not a duplicate + required: true diff --git a/claude-code-rust/.github/dependabot.yml b/claude-code-rust/.github/dependabot.yml new file mode 100644 index 0000000..c911152 --- /dev/null +++ b/claude-code-rust/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + # Rust dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "type: dependencies" + commit-message: + prefix: "chore" + include: "scope" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "type: dependencies" + - "area: ci" + commit-message: + prefix: "ci" diff --git a/claude-code-rust/.github/pull_request_template.md b/claude-code-rust/.github/pull_request_template.md new file mode 100644 index 0000000..1626584 --- /dev/null +++ b/claude-code-rust/.github/pull_request_template.md @@ -0,0 +1,26 @@ +## Summary + + + +Closes # + +## Changes + +- + +## Checklist + +- [ ] `cargo fmt --all -- --check` passes +- [ ] `cargo clippy --all-targets --all-features -- -D warnings` passes +- [ ] `cargo test --all-features` passes +- [ ] `cargo check --all-features` passes (MSRV 1.88.0) +- [ ] `cargo fetch --locked` passes (lockfile up to date) +- [ ] Tests added/updated for behavior changes (or N/A) +- [ ] Docs updated for user-facing/config/CLI changes (or N/A) +- [ ] Breaking changes are clearly documented (or N/A) +- [ ] UI/TUI changes include screenshot/recording (or N/A) +- [ ] Manual validation performed for changed flows (brief notes below) + +## Validation Notes + + diff --git a/claude-code-rust/.github/workflows/audit.yml b/claude-code-rust/.github/workflows/audit.yml new file mode 100644 index 0000000..abd288d --- /dev/null +++ b/claude-code-rust/.github/workflows/audit.yml @@ -0,0 +1,15 @@ +name: Security Audit + +on: + schedule: + - cron: '0 0 * * 1' + workflow_dispatch: + +jobs: + audit: + name: Cargo Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/audit@v1 + name: Audit Rust dependencies diff --git a/claude-code-rust/.github/workflows/ci.yml b/claude-code-rust/.github/workflows/ci.yml new file mode 100644 index 0000000..b0a805b --- /dev/null +++ b/claude-code-rust/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run tests + run: cargo test --all-features + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Run Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + fmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Check formatting + run: cargo fmt --all -- --check + + msrv: + name: MSRV Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.88.0" + - uses: Swatinem/rust-cache@v2 + - name: Check MSRV + run: cargo check --all-features + + lockfile: + name: Lockfile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Check lockfile + run: cargo fetch --locked diff --git a/claude-code-rust/.github/workflows/commit-lint.yml b/claude-code-rust/.github/workflows/commit-lint.yml new file mode 100644 index 0000000..44658bb --- /dev/null +++ b/claude-code-rust/.github/workflows/commit-lint.yml @@ -0,0 +1,27 @@ +name: Commit Lint + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +jobs: + lint: + name: Lint PR Title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + chore + ci + revert + requireScope: false diff --git a/claude-code-rust/.github/workflows/release-dryrun.yml b/claude-code-rust/.github/workflows/release-dryrun.yml new file mode 100644 index 0000000..a63233f --- /dev/null +++ b/claude-code-rust/.github/workflows/release-dryrun.yml @@ -0,0 +1,51 @@ +name: Release Dry Run + +on: + pull_request: + branches: [main] + paths: + - 'Cargo.toml' + +jobs: + dry-run-publish: + name: Validate npm publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Fetch base branch for version comparison + run: git fetch origin ${{ github.base_ref }} --depth=1 + + - name: Get crate version + id: get_version + run: | + VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + BASE_VERSION=$(git show origin/${{ github.base_ref }}:Cargo.toml | grep '^version' | head -1 | sed 's/.*"\(.*\)".*/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "base_version=$BASE_VERSION" >> $GITHUB_OUTPUT + if [ "$VERSION" = "$BASE_VERSION" ]; then + echo "changed=false" >> $GITHUB_OUTPUT + echo "Version unchanged ($VERSION), skipping dry-run publish" + else + echo "changed=true" >> $GITHUB_OUTPUT + echo "Version changed: $BASE_VERSION -> $VERSION" + fi + + - name: Setup Node.js + if: steps.get_version.outputs.changed == 'true' + uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Install agent-sdk dependencies + if: steps.get_version.outputs.changed == 'true' + run: npm ci --prefix agent-sdk + + - name: Sync package version from Cargo.toml + if: steps.get_version.outputs.changed == 'true' + run: npm version "${{ steps.get_version.outputs.version }}" --no-git-tag-version --allow-same-version + + - name: Dry-run publish + if: steps.get_version.outputs.changed == 'true' + run: npm publish --dry-run --access public diff --git a/claude-code-rust/.github/workflows/release-npm.yml b/claude-code-rust/.github/workflows/release-npm.yml new file mode 100644 index 0000000..c62692d --- /dev/null +++ b/claude-code-rust/.github/workflows/release-npm.yml @@ -0,0 +1,152 @@ +name: Release + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'Cargo.toml' + +permissions: + contents: write + id-token: write + +jobs: + verify: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.version }} + tag: ${{ steps.get_version.outputs.tag }} + tag-exists: ${{ steps.check_tag.outputs.exists }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get crate version + id: get_version + run: | + VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + + - name: Check if tag exists + id: check_tag + run: | + if git rev-parse "refs/tags/${{ steps.get_version.outputs.tag }}" >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + build-binaries: + needs: [verify] + if: needs.verify.outputs.tag-exists == 'false' + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + exe_suffix: "" + - os: windows-latest + target: x86_64-pc-windows-msvc + exe_suffix: ".exe" + - os: macos-15-intel + target: x86_64-apple-darwin + exe_suffix: "" + - os: macos-15 + target: aarch64-apple-darwin + exe_suffix: "" + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Build release binary + run: cargo build --release --locked --target ${{ matrix.target }} --bin claude-rs + + - name: Stage release asset + shell: bash + run: | + BINARY="claude-rs${{ matrix.exe_suffix }}" + ASSET="claude-code-rust-${{ matrix.target }}${{ matrix.exe_suffix }}" + mkdir -p dist + cp "target/${{ matrix.target }}/release/$BINARY" "dist/$ASSET" + + - name: Upload staged asset + uses: actions/upload-artifact@v7 + with: + name: claude-code-rust-${{ matrix.target }} + path: dist/* + if-no-files-found: error + + create-release: + needs: [verify, build-binaries] + if: needs.verify.outputs.tag-exists == 'false' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Create and push tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${{ needs.verify.outputs.tag }}" -m "Release ${{ needs.verify.outputs.tag }}" + git push origin "${{ needs.verify.outputs.tag }}" + + - name: Download all assets + uses: actions/download-artifact@v8 + with: + pattern: claude-code-rust-* + merge-multiple: true + path: dist + + - name: Extract changelog entry + run: | + VERSION="${{ needs.verify.outputs.version }}" + awk '/^## \['"$VERSION"'\]/{found=1; next} found && /^## \[/{exit} found{print}' CHANGELOG.md > release-notes.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.verify.outputs.tag }} + files: dist/* + fail_on_unmatched_files: true + body_path: release-notes.md + + publish-npm: + needs: [verify, create-release] + if: needs.verify.outputs.tag-exists == 'false' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Install agent-sdk dependencies + run: npm ci --prefix agent-sdk + + - name: Sync package version from Cargo.toml + run: npm version "${{ needs.verify.outputs.version }}" --no-git-tag-version --allow-same-version + + - name: Publish package to npm + run: npm publish --access public --provenance diff --git a/claude-code-rust/.gitignore b/claude-code-rust/.gitignore new file mode 100644 index 0000000..7856c0f --- /dev/null +++ b/claude-code-rust/.gitignore @@ -0,0 +1,46 @@ +# Generated by Cargo +# Will have compiled files and executables +debug/ +target/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo-mutants +**/mutants.out*/ + +# IDE / Editor +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +node_modules/ +.npm-cache/ +/vendor/ +/agent-sdk/dist/ + +# Local Claude Code config +.claude/ + +# Local Codex config +.codex/ + +# Logs for testing +*.log + +# Personal Notes etc. +/notes +/analysis/ +/AGENTS.md diff --git a/claude-code-rust/CHANGELOG.md b/claude-code-rust/CHANGELOG.md new file mode 100644 index 0000000..f54f74f --- /dev/null +++ b/claude-code-rust/CHANGELOG.md @@ -0,0 +1,443 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.9.0] - 2026-03-26 [Changes][v0.9.0] + +### Features + +- **Two-line footer replaces header** (#102): Remove the header and consolidate location, branch, mode badges, permission counts, and MCP auth hints into a two-row adaptive footer + +### Fixes + +- **Unified viewport geometry handling** (#101): Single geometry entry point with separate width/height semantics and tail invalidation on topology changes +- **Centralized geometry state and wrapped panel measurement** (#101): Immediate resize geometry refresh and wrapped-text measurement replacing fixed-height panel assumptions +- **Topology invalidation and batch message dirtiness** (#101): Tracked insert/remove/clear paths own tool index and terminal ref repair +- **Active turn ownership across history pruning** (#101): Keep active assistant turn out of retention drop candidates and remap ownership after pruning +- **Scroll anchor preservation** (#101): Delay anchor restore until heights are exact and preserve anchors across pruning and marker operations +- **Unified message layout model** (#101): Shared `MessageLayout` replaces split role-specific render/measure branches +- **Turn cleanup normalization** (#101): Single cleanup boundary for resume, cancel, auth-required, connection-failure, and fatal exits +- **Chat focus ownership** (#101): Rebuild focus from surviving state on transitions and render selected prompt choices in rust orange +- **Streaming invalidation and selection snapshots** (#101): Refresh selection snapshots on redraw and protect active streaming assistant in cache budgeting +- **Session state reset at authority boundaries** (#101): Scope async responses to the active session epoch and discard stale results +- **Persisted authority reconciliation** (#101): Rederive trust state from current cwd on reconnect and clear stale session identity on failure boundaries +- **Tool index rebuilds and multi-index sync** (#101): Gate scope updates on successful lookup and normalize interaction queues to prevent stale prompt drops +- **Display-width-aware copy** (#101): Slice copied text by display columns so emoji, CJK, and combining marks match visual selection +- **Esc cancel and queued submit lifecycle** (#101): Clear deferred submit on Esc and let manual cancel override auto-resubmit + +### Performance + +- **Offscreen row skip and wrapped-height culling** (#101): Render from the first visible message's structural offset and use exact wrapped row coverage for culling + +## [0.8.4] - 2026-03-23 [Changes][v0.8.4] + +### Fixes + +- **Chat bottom-height drift**: Stop rendering and measuring the trailing separator row after the final chat message so auto-scroll no longer lands on a persistent empty line beneath the last Claude response; add regression tests for last-message rendering and height measurement + +## [0.8.3] - 2026-03-23 [Changes][v0.8.3] + +### Performance + +- **Unified layout invalidation and progressive remeasure** (#98): Replace the `dirty_from` suffix watermark with per-message staleness tracking, separated prefix-sum dirtiness, and a visible-first remeasure plan; preserve scroll anchors across in-flight resize and global remeasure replacement; single-message updates do exact changed-message remeasure plus targeted prefix repair instead of invalidating the entire suffix +- **Incremental history retention accounting** (#98): Cache per-message retained-byte estimates and maintain a rolling total so retention enforcement stops rescanning the full message list every cycle; cache tool `raw_input` byte estimates to avoid repeated JSON serialization in hot paths +- **Incremental render cache budget** (#98): Replace per-frame full cache budget scans with incremental slot metadata, rolling byte totals, and a pre-sorted eviction set rebuilt only when over budget +- **Derive tool collapse state at render time** (#98): Remove per-tool `collapsed` field; `tools_collapsed` is the session-level source of truth read at render time so Ctrl+O no longer walks and mutates every tool-call block +- **Index terminal tool-call refs** (#98): Replace linear duplicate checks on terminal subscriptions with a `HashSet` membership index; route attach, detach, and rebuild through shared tracking helpers + +### Dependencies + +- Bump `aws-lc-sys` from 0.38.0 to 0.39.0 and `aws-lc-rs` from 1.16.1 to 1.16.2 (fixes RUSTSEC-2026-0044, RUSTSEC-2026-0048) +- Bump `rustls-webpki` from 0.103.9 to 0.103.10 (fixes RUSTSEC-2026-0049) +- Bump `pulldown-cmark` from 0.13.1 to 0.13.3 (#97) + +## [0.8.2] - 2026-03-18 [Changes][v0.8.2] + +### Fixes + +- **Startup service status handling**: Keep startup Claude service warnings and errors as transcript messages only; status errors no longer clear the draft or block users from trying a request during partial or uneven outages + +## [0.8.1] - 2026-03-18 [Changes][v0.8.1] + +### Fixes + +- **Startup session settings propagation**: Pass configured `model` and `defaultPermissionMode` through the SDK's top-level session startup options so new sessions start with the expected live model and permission mode instead of falling back to provisional defaults +- **Welcome banner model sync**: Keep the welcome banner and header aligned through `Connecting...`, provisional `default`, and the first authoritative model update; freeze the welcome banner once the session model is resolved while allowing the header to continue tracking live model changes +- **Claude status relevance filtering**: Query the status summary endpoint and only surface startup warnings for `Claude Code` and `Claude API`, avoiding false-positive outage banners caused by unrelated Anthropic components +- **Config cleanup**: Remove obsolete MCP callback overlay code and stale config UI expectations left behind by the MCP management changes + +## [0.8.0] - 2026-03-17 [Changes][v0.8.0] + +### Features + +- **Agent SDK 0.2.74 migration** (#83): Upgrade from SDK 0.2.63 to 0.2.74; inline session settings replace per-flag overrides; agent progress summaries rendered in task tool-call bodies; model capability badges (adaptive thinking, fast mode, auto mode) shown in settings overlay +- **AskUserQuestion support** (#83): Dedicated question/response bridge path with horizontal and vertical option layouts; multi-select state tracking; inline annotation editing; question progress indicator; shared focus cycling infrastructure with permissions +- **MCP management tab** (#89): Live MCP server list with connection status indicators and tool counts; `/mcp` slash command; server detail overlay with context-aware actions (reconnect, toggle, authenticate, clear auth); OAuth authentication flow with browser launch and manual callback URL entry; elicitation support for URL-based and form-based modes; stale status revalidation with 30-second cooldown auto-reconnect +- **Usage tab with quota visualization** (#88, closes #87): Dual-source fetching (OAuth API first, CLI fallback); gauge bars with color-coded utilization (green/yellow/red); 5-hour, 7-day, and per-model quota windows; extra credits panel; `/usage` slash command and `r` manual refresh; 30-second TTL caching; OAuth credential expiry validation +- **Plugin management** (#85): Three-section Plugins tab (Installed, Marketplace, Marketplace Sources) with CLI-backed operations; install/enable/disable/update/uninstall actions via overlay dialogs; marketplace source add/remove with text input; MCP capability badges; `/plugins` slash command; 5-second inventory cache with background refresh +- **Status tab and /status command** (#80): Session, account, model, and settings information display; lazy account snapshot fetching via SDK; login method labels (Claude Max, API key variants); session name resolution with custom title/summary/prompt fallback chain; memory path and active setting sources display +- **Unified syntax highlighting** (#84): Replace `ansi-to-tui` with `syntect` for theme-aware coloring across shell commands, code blocks, and terminal output; ANSI escape stripping state machine; automatic raw unified diff detection and semantic coloring; language-aware code highlighting with extension-based syntax detection +- **Session management enhancements** (#83): Repository-scoped session discovery with worktree inclusion; session rename and AI-generated title actions in Status tab; text overlay for rename input with immediate visual feedback +- **Tool output metadata** (#83): Structured metadata for Bash (assistant-backgrounded badge, token-saver state), ExitPlanMode (ultraplan badge), TodoWrite (verification-needed badge), and Write/Edit (git repository labels in diff headers); MCP resource content typing with URI, MIME type, and blob saved-path hints + +### Fixes + +- **Paste-handled Enter suppression** (#83): Treat paste-handled Enter as fully consumed in key dispatch; prevent suppressed paste newlines from falling through to non-char cleanup; restores inline multiline paste insertion for sub-1000 character payloads +- **Bell notification fallback** (#83): Restore bell alongside desktop notifications on terminals without OSC 9 support; keep auto notification routing aligned with pre-settings behavior + +### UI + +- **Config tab activation helper** (#88): Centralized `activate_tab` function for consistent tab-switch behavior across `/plugins`, `/status`, `/usage`, `/mcp`, and keyboard navigation +- **Shared text input widget** (#85): Reusable `text_input_line()` and `render_text_input_field()` components used by Language, Session Rename, and Add Marketplace overlays +- **Setting stepping** (#85): Left/Right arrow editing for enum settings (Theme, Notifications, EditorMode, DefaultPermissionMode) in config view + +### Performance + +- **Progressive resize height recomputation** (#90): Replace synchronous full-remeasure on terminal width change with frame-budgeted progressive convergence; scroll anchor preservation across resize; per-message exactness tracking with expanding measurement frontiers around the visible window; measurement budget of max(12, viewport_height) messages and max(256, viewport_height * 8) wrapped lines per frame +- **Background file walker for mentions** (#81): Replace synchronous BFS with `ignore` crate `WalkBuilder` on a background thread; query refinement refilters from cache instead of restarting the walk; pre-computed lowercase path variants eliminate per-sort allocation; bounded channel (1024 entries) with 500-entry drain budget per tick +- **Input redraw and cache optimization** (#82): Split input versioning into cursor-only and content epochs; syntax highlighting and height measurement caches keyed on content version; key handlers return visibility signals to suppress redraws for non-visual events; Windows paste burst jitter tolerance in pending confirmation window +- **Background bash terminal detachment** (#86): Detach terminal references when Bash tools reach completed or failed state; skip polling for non-running tools; prevent late bridge progress updates from reopening finalized tools; clear execute terminal references during forced tool finalization +- **Dev/test profile tuning** (#83): Line-tables-only debug info and disabled incremental builds for faster compilation + +### Licensing + +- **Apache-2.0**: Switch project license from AGPL-3.0-or-later to Apache-2.0; SPDX single-line identifiers replace full header blocks across all source files + +### CI and Dependencies + +- Bump `@anthropic-ai/claude-agent-sdk` from 0.2.63 to 0.2.74 (#83) +- Replace `ansi-to-tui` with `syntect` 5.3.0 (#84) +- Bump `tui-textarea-2` from 0.10.1 to 0.10.2 (#82) +- Add version comparison to release dry-run workflow to skip unchanged versions (#82) + +## [0.7.1] - 2026-03-12 [Changes][v0.7.1] + +### Fixes + +- **npm rollback for installs and releases**: Revert active package manager guidance, package scripts, and GitHub release workflows from `pnpm` back to `npm`; runtime reinstall guidance now recommends `npm install -g` +- **Leading blank row before Claude text**: Trim leading rendered blank rows before the first visible assistant text block while preserving paragraph spacing for later content +- **Deferred Enter submit stability**: Plain `Enter` now snapshots and restores the exact draft instead of mutating the input before submit, fixing hidden newline leaks when the cursor is in the middle of the text + +## [0.7.0] - 2026-03-12 [Changes][v0.7.0] + +### Features + +- **Native `/login` and `/logout` commands** (#67): Shell out to `claude auth login`/`logout` with TUI suspend/resume; credential verification reads `~/.claude/.credentials.json` directly; skip redundant operations when already authenticated or not authenticated +- **User settings system** (#74): 14 persisted settings across three JSON files (`~/.claude/settings.json`, `/.claude/settings.local.json`, `~/.claude.json`); metadata-driven two-column config view with compact narrow-terminal fallback; toggle/cycle/overlay mutation with immediate persistence +- **Workspace trust** (#74): Startup gated on per-project trust acceptance; path normalization for Windows drive letters, UNC paths, and symlinks; trust state persisted in `~/.claude.json` +- **Session launch settings** (#74): Saved preferences (model, language, permission mode, thinking mode, effort level) propagate into every new session via `SessionLaunchSettings`; available models flowed back from SDK for dynamic UI +- **Cancel-and-resubmit** (#68): Submitting while the agent is running cancels the current turn and auto-resubmits once ready; draft stays visible and editable throughout with cancellation spinner banner +- **Desktop and bell notifications** (#68, #74): `NotificationManager` tracks terminal focus via DECSET 1004; fires bell + OS-native desktop toasts on permission requests and turn completion when unfocused; channel-based delivery (disabled, bell, OSC 9, desktop) driven by user preference +- **Compaction overhaul** (#68): `/compact` keeps chat history and appends a success system message after the turn completes; input and keyboard blocked during compaction; auto-compaction clears silently without a banner +- **Cache observability** (#69): `CacheMetrics` accumulator with rate-limited structured tracing; warn-level alerts for high utilization and eviction spikes with cooldown; integration test suite covering the full stream-to-split-to-measure-to-prefix-sums pipeline +- **Unified textarea input** (#70): Replace snapshot-based input state plus shadow editor with one persistent `TextArea` as source of truth; fixes wrapped visual-row cursor navigation; `&` subagent autocomplete now eager, matching `@` and `/` behavior +- **Incremental mention search** (#74): Replace repeated full rescans with incremental BFS (400 entries/tick budget) and 4-tier ranking; `.gitignore` awareness with global, local, and nested rule support; search threshold lowered from 3 characters to 1 + +### Fixes + +- **Permission `allow_always` persistence** (#68, #71): Synthesize persistent `addRules` fallback when the SDK omits suggestions, fixing silent degradation to one-time allow; `allow_always` fallback now persists to `localSettings` +- **Paste burst reliability** (#71): Reworked burst detection into a timing-based state machine (`Idle`/`Pending`/`Buffering`) with idle flush; retro-capture cleanup for leaked leading characters; enter suppression during and immediately after paste; CRLF normalized to LF in `insert_str` +- **Bridge `reject_once` match arm** (#68): Added missing match arm that caused spurious warning logs on every permission prompt + +### UI + +- **Help panel keyboard navigation** (#67): Up/Down scroll and selection for Slash Commands and Subagents tabs; dynamic visible-item computation from wrapped text heights; fixed panel height across tabs; orange highlight for selected item +- **Paragraph gap preservation** (#74): `TextBlock` state with trailing spacing metadata preserves paragraph gaps in chat rendering +- **Built-in slash entries** (#74): `/config`, `/login`, `/logout` appear in slash help and autocomplete +- **Input blocking during async commands** (#67): General `CommandPending` status with dynamic spinner text used by `/login`, `/logout`, `/mode`, `/model`, `/new-session`, `/resume` + +### Performance + +- **Capacity-based byte accounting** (#69): Replace heuristic message sizing with `IncrementalMarkdown::text_capacity` measurement +- **Protected-bytes tracking** (#69): Non-evictable streaming-tail blocks excluded from eviction targets in render budget enforcement +- **Layout invalidation consolidation** (#69): `InvalidationLevel` enum and `invalidate_layout` helper replace ad-hoc invalidation across event flows + +### Refactoring + +- **Codebase split** (#68): `ui/tool_call.rs`, `app/connect.rs`, `app/slash.rs`, `app/state.rs`, `app/events.rs` split into submodule directories (4-9 files each); `bridge.ts` split into 8 files under `bridge/`; all public APIs preserved via re-exports +- **Usage pipeline removal** (#68): Delete entire `usage_update` pipeline (bridge/usage.ts, UsageUpdate types, session/message token tracking, footer cost display) across TypeScript and Rust +- **Input state unification** (#70): Remove rebuild/sync debt from snapshot-based input; route all input reads/writes through accessor + replace flows on a single `TextArea` instance + +### CI and Dependencies + +- Bump `uuid` from 1.21.0 to 1.22.0 (#72) +- Bump `which` from 8.0.0 to 8.0.2 (#73) +- Switch `tui-textarea-2` from local path dependency to crates.io `0.10.1` (#70) +- Switch remaining npm command surfaces to pnpm across workflows, docs, and scripts (#74) + +## [0.6.0] - 2026-03-03 [Changes][v0.6.0] + +### Features + +- **Agent SDK 0.2.63 migration** (#64): Upgrade from SDK 0.2.52 to 0.2.63; align bridge, wire types, and session APIs with the new SDK surface +- **Fast mode support** (#64): Wire fast mode state end-to-end from bridge to TUI; footer badge shows `FAST` or `FAST:CD` during cooldown; deduplicated state change emission +- **Rate limit updates** (#64): Parse and display rate limit events with readable user-facing summaries including overage and reset timing +- **Available agents and subagent autocomplete** (#64): Wire `available_agents_update` across bridge and rust layers; `&` ampersand autocomplete for subagents; new Subagents help tab with two-column layout +- **Session resume via SDK-native APIs** (#64): Replace legacy JSONL parsing with `resume_session` backed by `listSessions` and `getSessionMessages`; align to SDK session metadata fields +- **Interactive plan approval** (#61): Intercept `ExitPlanMode` and render structured Approve/Reject widget with arrow navigation, `y`/`n` quick shortcuts, and `allowedPrompts` display +- **Write diff capping** (#61): Truncate Write tool diffs exceeding 50 lines to head/tail window with omission marker; auto-scroll on oversized writes; plan files exempted +- **Startup service status checks** (#65): Query status.claude.com during startup; emit warning or error system messages; lock input on outage-level errors +- **Subagent thinking indicator** (#60): Debounced (1500ms) idle indicator between subagent tool calls to avoid flicker; suppress normal spinner when subagent indicator is active +- **System message severity levels** (#64): Replace `SystemWarning` with `MessageRole::System` plus Info/Warning/Error severity with matching label colors +- **Slash command output in transcript** (#64): Local slash command results now surface in assistant transcript + +### Fixes + +- **Context percentage formula** (#61): Exclude `output_tokens` from context calculation -- Anthropic input formula is cache_read + cache_creation + input only; context % now updates as soon as `input_tokens` arrive +- **Stale task scope cleanup** (#65): Clear `active_task_ids` on tool scope reset to prevent subagent misclassification after cancelled tasks +- **Subagent indicator false positives** (#65): Gate thinking indicator on active spinner state to prevent false idle rendering +- **SDK rejection sanitization** (#65): Harden bridge rejection replacement with exact and known-prefix matching only on failed tool results +- **Saturating coordinate math** (#65): Use saturating arithmetic for header, input, autocomplete, and todo padding to prevent overflow panics + +### UI + +- **Footer module extraction** (#61): Move all footer logic into dedicated `src/ui/footer.rs`; clean up imports in `mod.rs` +- **Autocomplete stabilization** (#61): Stable popover width; shift left near right edge; UTF-8-safe case-insensitive highlight ranges +- **Help overlay improvements** (#64): Add subagents tab; move tab-switch hint into help title; rename footer hint from "Shortcuts + Commands" to "Help" + +### Refactoring + +- **Handler decomposition** (#61): Split large connection/event/key/slash/message handlers into smaller helpers; remove `clippy::too_many_lines` suppressions; `FocusContext` builder-style API + +### CI and Dependencies + +- Bump `actions/upload-artifact` from 6 to 7 (#62) +- Bump `actions/download-artifact` from 7 to 8 (#63) +- Migrate from npm `package-lock.json` to pnpm `pnpm-lock.yaml` in agent-sdk (#64) + +## [0.5.1] - 2026-02-27 [Changes][v0.5.1] + +### Fixes + +- **Input smoothness during rapid keys**: Restore frame rendering during non-paste active key bursts by narrowing suppression to confirmed paste bursts only; preserves paste placeholder anti-flicker behavior + +## [0.5.0] - 2026-02-27 [Changes][v0.5.0] + +### Features + +- **Paste handling overhaul** (#53): Character-count threshold (1000 chars) replaces line-count; placeholder label updated; session identity tracking prevents append across separate pastes; burst finalization scoped to newly pasted range only +- **Turn error classification** (#54): `TurnError` strings matched against known patterns (rate limit, plan limit, max turns, quota, 429); actionable recovery hint pushed as a system message in chat; unclassified errors preserve existing behavior + +### Fixes + +- **Typed `AppError` enum** (#54): `NodeNotFound`, `AdapterCrashed`, `AuthRequired`, `ConnectionFailed`, `SessionNotFound` variants with per-variant exit codes and user-facing messages + +### Performance + +- **Unified cache budgeting + LRU history retention** (#52): Single cache budget across all message blocks; LRU eviction for long sessions; reduces memory growth on extended conversations + +### UI + +- **Footer three-column layout**: Update hint and context percentage now render in separate right-aligned columns simultaneously instead of either-or + +## [0.4.1] - 2026-02-27 [Changes][v0.4.1] + +### Fixes + +- **Dynamic bridge log levels** (`client.rs`): Bridge stderr lines are now routed to the correct tracing level -- `[sdk error]`/panic lines go to `error!`, `[sdk warn]` lines to `warn!`, and ordinary SDK chatter to `debug!` -- instead of unconditionally emitting `error!` for every line +- **Height cache invalidated on interruption** (`events.rs`): `TurnComplete` and `TurnError` now call `mark_message_layout_dirty` on the tail assistant message so the height cache is re-measured after a cancelled or failed turn, fixing stale layout after interruption + +## [0.4.0] - 2026-02-27 [Changes][v0.4.0] + +### Features + +- **Agent SDK migration** (#45, closes #23): Replace `@zed-industries/claude-code-acp` with the in-repo Agent SDK bridge; align permission suggestions with SDK session/always-allow scope +- **Session resume** (#46, closes #22): `--resume` is cwd-aware and restores full transcript state; input locked while resuming; recent sessions shown in welcome context +- **Token and cost tracking** (#47, closes #21): Footer shows live `Context: XX%`; assistant turns show per-turn `(Xk tok / $X.XX)`; compaction spinner during SDK-reported compaction +- **Slash command popovers and AskUserQuestion** (#48): Variable-input slash commands show dynamic argument popovers; full `AskUserQuestion` flow with option rendering and answer propagation + +### Fixes + +- **TodoWrite flicker** (#45): Ignore transient payloads without a todos array so the list no longer clears and reappears mid-turn +- **Failed Bash rendering** (#45): Compress failed tool output to a single exit-code summary line instead of the full stderr dump +- **Ctrl+C determinism** (#46): Copy only when selection is non-empty and clear it after; otherwise quit +- **Submission pipeline** (#47): Single queue gate for submissions; cancel active turn before dispatching queued action; wait for turn-settle before ready +- **Persisted tool-result normalization** (#48): Strip leading box-drawing prefixes from tool result summaries + +### Performance + +- **Streaming frame cost** (#49): Generation-keyed tool call measurement cache with O(1) fast path; terminal output delta-append; skip invalidation for no-op updates + +### Internal + +- Agent SDK bridge modularized into focused modules (`commands.ts`, `tooling.ts`, `permissions.ts`, `usage.ts`, `history.ts`, `auth.ts`, `shared.ts`) (#48) +- Perf instrumentation markers for key invalidation, measurement, and snapshot paths (#49) + +## [0.3.0] - 2026-02-25 [Changes][v0.3.0] + +### Features + +- **Startup update check** (#30): Non-blocking check via GitHub Releases API with 24h cache, footer hint, `Ctrl+U` dismiss, `--no-update-check` / `CLAUDE_RUST_NO_UPDATE_CHECK=1` opt-out +- **Shortcuts during connecting** (#38): Navigation and help shortcuts work while ACP adapter connects; input keys remain blocked +- **Global Ctrl+Q quit** (#38): Safe quit available in all states including connecting and error +- **Input height API and word wrapping** (#40): Adopt tui-textarea-2 v0.10 `TextArea::measure()` for input sizing, switch to `WrapMode::WordOrGlyph`, remove custom `InputWrapCache` plumbing + +### Fixes + +- **Height cache recalculation** (#39): Track dirty message index and re-measure non-tail messages when content or tool blocks change +- **Error state and input locking** (#39): Connection and turn failures surface immediately with quit hint; input blocked during connecting/error +- **Scroll clamp after permission collapse** (#39): Clamp overscroll when content shrinks; ease scroll position for smooth settling; consume Up/Down with single pending permission +- **Permission shortcut reliability** (#29): `Ctrl+Y/A/N` work globally while prompts are pending with fallback option matching +- **Tool-call error rendering** (#29): Improved error handling with raw_output fallback and cleaner failed-call display + +### CI and Dependencies + +- Bump `actions/upload-artifact` 4 to 6, `actions/setup-node` 4 to 6, `actions/download-artifact` 5 to 7 (#31, #32, #33) +- Bump `pulldown-cmark` from 0.13.0 to 0.13.1 (#34) +- Unify cargo publish, binary build, GitHub release, and npm publish into one workflow (#30) +- Add `revert` to allowed semantic PR title types (#37) + +### Internal + +- Attempted migration to `claude-agent-acp` (#29), reverted to `claude-code-acp` (#37) due to feature parity gaps +- Regression tests for height remeasurement, scroll clamp, permission keys, connecting shortcuts, and update check + +## [0.2.0] - 2026-02-22 [Changes][v0.2.0] + +### Rename and Distribution + +- Rename crate/package to `claude-code-rust` +- Rename command to `claude-rs` +- Update release workflows and artifacts to publish/build under the new names + +## [0.1.3] - 2026-02-21 [Changes][v0.1.3] + +### Fixes + +- Rescan files on each `@` mention activation so new/deleted files are reflected during a session +- Add keywords to npm package.json for better discoverability + +## [0.1.2] - 2026-02-21 [Changes][v0.1.2] + +### UX and Interaction + +- Add OS-level shutdown signal handling (`Ctrl+C`/`SIGTERM`) so external interrupts also trigger graceful TUI teardown +- Keep in-app `Ctrl+C` key behavior for selection copy versus quit, while unifying shutdown through the existing cleanup path +- Make chat scrollbar draggable with proportional thumb-to-content mapping +- Ensure scrollbar dragging can reach absolute top and bottom of chat history + +## [0.1.1] - 2026-02-21 [Changes][v0.1.1] + +### CI and Release + +- Replace release-plz with direct cargo and npm publish workflows +- `release-cargo.yml`: publishes to crates.io on Cargo.toml version bump +- `release-npm.yml`: builds cross-platform binaries, creates verified GitHub Release, publishes to npm with provenance +- Triggers based on Cargo.toml version changes instead of tag chaining +- Tags created by github-actions[bot] for verified provenance +- Remove release-plz.toml and cliff.toml + +## [0.1.0] - 2026-02-20 [Changes][v0.1.0] + +### Release Summary + +`Claude Code Rust` reaches a strong pre-1.0 baseline with near feature parity for core Claude Code terminal workflows: + +- Native Rust TUI built with Ratatui and Crossterm +- ACP protocol integration via `@zed-industries/claude-code-acp` +- Streaming chat, tool calls, permissions, diffs, and terminal command output +- Modern input UX (multiline, paste burst handling, mentions, slash commands) +- Substantial rendering and scrolling performance work for long sessions +- Broad unit and integration test coverage across app state, events, permissions, and UI paths + +The only major parity gap intentionally excluded from this release is token/cost usage display because the upstream ACP adapter currently does not emit usage data. + +### Architecture And Tooling + +- Three-layer runtime design: + - Presentation: Rust + Ratatui + - Protocol: ACP over stdio + - Agent: Zed ACP adapter process +- Async runtime and event handling: + - Tokio runtime with ACP work kept on `LocalSet` (`!Send` futures) + - `mpsc` channels between ACP client events and UI state machine +- CLI and platform support: + - Clap-based CLI (`--model`, `--resume`, `--yolo`, `-C`, adapter/log/perf flags) + - Cross-platform adapter launcher fallback (explicit path, env path, global bin, npx) + - Windows-safe process resolution via `which` + +### Core Features + +- Chat and rendering: + - Native markdown rendering including tables + - Inline code/diff presentation and tool-call block rendering + - Welcome/system/tool content unified in normal chat flow +- Input and commands: + - `tui-textarea-2` powered editor path + - Multiline paste placeholder pipeline and burst detection + - `@` file/folder mention autocomplete with resource embedding + - Slash command workflow with ACP-backed filtering and help integration +- Tool execution UX: + - Unified inline permission controls inside tool-call blocks + - Focus-aware keyboard routing for mention, todo, and permission contexts + - Better interruption semantics and stale spinner cleanup + - Internal ACP/adapter failures rendered distinctly from normal command failures +- Session and app UX: + - Parallel startup (TUI appears immediately while ACP connects in background) + - In-TUI connecting/auth failure messaging and login hinting + - Header model/location/branch context + - Help overlay and shortcut discoverability improvements + - Mouse selection and clipboard copy support + - Smooth chat scroll and minimal scroll position indicator + +### Performance Work + +Performance optimization was a major release theme across recent commits: + +- Block-level render caching and deduplicated markdown parsing +- Incremental markdown handling in streaming scenarios +- Prefix sums + binary search for first visible message +- Viewport culling for long-chat scaling +- Ground-truth height measurement and improved resize correctness +- Conditional redraw paths and optional perf diagnostics logging +- Additional targeted UI smoothing for scroll and scrollbar transitions + +### Reliability, Quality, And Tests + +- Significant test investment across both unit and integration layers +- Current codebase includes over 400 Rust `#[test]` cases +- Dedicated integration suites for ACP events, tool lifecycle, permissions, state transitions, and internal failure rendering +- CI includes test, clippy (`-D warnings`), fmt, MSRV, and lockfile checks + +### Release And Distribution Setup + +- Rust crate is now publish-ready for crates.io as `claude-code-rust` +- CLI executable name is `claude-rs` +- npm global package added as `claude-code-rust`: + - installs `claude-rs` command + - downloads matching GitHub release binary during `postinstall` +- Tag-based GitHub Actions release workflow added for: + - cross-platform binary builds (Windows/macOS/Linux) + - GitHub release asset publishing + - npm publishing (when `NPM_TOKEN` is configured) +- `release-plz` remains in place for release PR automation and changelog/version workflows + +### Known Limitations + +- Slash command availability is intentionally conservative for this release: + - `/login` and `/logout` are not offered + - they remain excluded until ACP/Zed support is reliable enough for production use +- Token usage and cost tracking is blocked by current ACP adapter behavior: + - `UsageUpdate` events are not emitted + - `PromptResponse.usage` is `None` +- Session resume (`--resume`) is blocked on an upstream adapter release that contains a Windows path encoding fix + +[v0.9.0]: https://github.com/srothgan/claude-code-rust/compare/v0.8.4...v0.9.0 +[v0.8.4]: https://github.com/srothgan/claude-code-rust/compare/v0.8.3...v0.8.4 +[v0.8.3]: https://github.com/srothgan/claude-code-rust/compare/v0.8.2...v0.8.3 +[v0.8.2]: https://github.com/srothgan/claude-code-rust/compare/v0.8.1...v0.8.2 +[v0.8.1]: https://github.com/srothgan/claude-code-rust/compare/v0.8.0...v0.8.1 +[v0.8.0]: https://github.com/srothgan/claude-code-rust/compare/v0.7.1...v0.8.0 +[v0.7.1]: https://github.com/srothgan/claude-code-rust/compare/v0.7.0...v0.7.1 +[v0.7.0]: https://github.com/srothgan/claude-code-rust/compare/v0.6.0...v0.7.0 +[v0.6.0]: https://github.com/srothgan/claude-code-rust/compare/v0.5.1...v0.6.0 +[v0.5.1]: https://github.com/srothgan/claude-code-rust/compare/v0.5.0...v0.5.1 +[v0.5.0]: https://github.com/srothgan/claude-code-rust/compare/v0.4.1...v0.5.0 +[v0.4.1]: https://github.com/srothgan/claude-code-rust/compare/v0.4.0...v0.4.1 +[v0.4.0]: https://github.com/srothgan/claude-code-rust/compare/v0.3.0...v0.4.0 +[v0.3.0]: https://github.com/srothgan/claude-code-rust/compare/v0.2.0...v0.3.0 +[v0.2.0]: https://github.com/srothgan/claude-code-rust/compare/v0.1.3...v0.2.0 +[v0.1.3]: https://github.com/srothgan/claude-code-rust/compare/v0.1.2...v0.1.3 +[v0.1.2]: https://github.com/srothgan/claude-code-rust/compare/v0.1.1...v0.1.2 +[v0.1.1]: https://github.com/srothgan/claude-code-rust/compare/v0.1.0...v0.1.1 +[v0.1.0]: https://github.com/srothgan/claude-code-rust/releases/tag/v0.1.0 diff --git a/claude-code-rust/CODE_OF_CONDUCT.md b/claude-code-rust/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..cb10a19 --- /dev/null +++ b/claude-code-rust/CODE_OF_CONDUCT.md @@ -0,0 +1,90 @@ + +# Contributor Covenant 3.0 Code of Conduct + +## Our Pledge + +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant. + + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors. + + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, **use [GitHub Security Advisories](https://github.com/srothgan/claude-code-rust/security/advisories/new) or contact the project maintainer, Simon Rothgang, at simonrothgang@icloud.com.** + +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution. + + +## Addressing and Repairing Harm + +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped. + +1) Warning + 1) Event: A violation involving a single incident or series of incidents. + 2) Consequence: A private, written warning from the Community Moderators. + 3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations. +2) Temporarily Limited Activities + 1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation. + 2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members. + 3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over. +3) Temporary Suspension + 1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation. + 2) Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4) Permanent Ban + 1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member. + 2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior. + 3) Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community. + + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) + +For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion). diff --git a/claude-code-rust/CONTRIBUTING.md b/claude-code-rust/CONTRIBUTING.md new file mode 100644 index 0000000..e375935 --- /dev/null +++ b/claude-code-rust/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Contributing to claude_rust + +Thank you for considering contributing to claude_rust! This document provides +guidelines and information for contributors. + +## Code of Conduct + +This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). +By participating, you agree to uphold this code. + +## How to Contribute + +### Reporting Bugs + +- Use the [Bug Report](../../issues/new?template=bug_report.yml) issue template +- Include reproduction steps, expected vs actual behavior, and environment details +- Run with `RUST_LOG=debug` and include relevant log output + +### Suggesting Features + +- Use the [Feature Request](../../issues/new?template=feature_request.yml) template +- Check existing issues and discussions first +- Describe the problem being solved, not just the desired solution + +### Submitting Code + +1. Fork the repository +2. Create a feature branch from `main`: `git checkout -b feat/my-feature` +3. Make your changes following the coding standards below +4. Add or update tests as appropriate +5. Ensure all checks pass: + ```bash + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + cargo test --all-features + ``` +6. Commit using [Conventional Commits](https://www.conventionalcommits.org/): + ``` + feat: add keyboard shortcut for tool collapse + fix: prevent panic on empty terminal output + ``` +7. Push to your fork and open a Pull Request against `main` +8. Fill out the PR template completely + +## Development Setup + +```bash +# Prerequisites +# - Rust 1.88.0+ (install via https://rustup.rs) +# - Node.js 18+ (for the ACP adapter) +# - npx (included with Node.js) + +# Clone and build +git clone https://github.com/srothgan/claude-code-rust.git +cd claude_rust +cargo build + +# Run +cargo run + +# Run with debug logging +RUST_LOG=debug cargo run + +# Run tests +cargo test + +# Check formatting +cargo fmt --all -- --check + +# Run lints +cargo clippy --all-targets --all-features -- -D warnings +``` + +## Coding Standards + +- **Formatting**: Use `rustfmt` (configured via `rustfmt.toml`) +- **Linting**: `cargo clippy` must pass with zero warnings (configured via `clippy.toml` and `Cargo.toml` `[lints.clippy]`) +- **Naming**: Follow [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/naming.html) +- **Error handling**: Use `thiserror` for library errors, `anyhow` in main/app +- **Comments**: Only where the logic isn't self-evident +- **License headers**: Every new `.rs` file should include `// SPDX-License-Identifier: Apache-2.0` + +## Architecture + +See [detailed-plan.md](notes/detailed-plan.md) for the full architecture and implementation plan. + +Key architectural decisions: +- ACP futures are `!Send` - all ACP code runs in `tokio::task::LocalSet` +- UI and ACP communicate via `tokio::sync::mpsc` channels +- The TUI uses Ratatui with Crossterm backend (cross-platform) + +## License + +By contributing, you agree that your contributions will be licensed under the +Apache-2.0 license, the same license as the project. diff --git a/claude-code-rust/Cargo.lock b/claude-code-rust/Cargo.lock new file mode 100644 index 0000000..c66f7d3 --- /dev/null +++ b/claude-code-rust/Cargo.lock @@ -0,0 +1,4759 @@ +# This file is automatically @generated by Cargo. +# 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 = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ansi-to-tui" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42366bb9d958f042bf58f0a85e1b2d091997c1257ca49bddd7e4827aadc65fd" +dependencies = [ + "nom 8.0.0", + "ratatui-core", + "simdutf8", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "claude-code-rust" +version = "0.9.0" +dependencies = [ + "anyhow", + "arboard", + "async-trait", + "clap", + "crossterm", + "dirs", + "futures", + "ignore", + "notify-rust", + "pretty_assertions", + "pulldown-cmark", + "ratatui", + "reqwest", + "serde", + "serde_json", + "similar", + "syntect", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "tui-markdown", + "tui-textarea-2", + "unicode-width", + "uuid", + "which", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.11.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.38.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex 0.16.2", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tui-markdown" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e766339aabad4528c3fccddf4acf03bc2b7ae6642def41e43c7af1a11f183122" +dependencies = [ + "ansi-to-tui", + "itertools", + "pretty_assertions", + "pulldown-cmark", + "ratatui-core", + "rstest", + "syntect", + "tracing", +] + +[[package]] +name = "tui-textarea-2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a31ca0965e3ff6a7ac5ecb02b20a88b4f68ebf138d8ae438e8510b27a1f00f" +dependencies = [ + "crossterm", + "portable-atomic", + "ratatui-core", + "ratatui-widgets", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/claude-code-rust/Cargo.toml b/claude-code-rust/Cargo.toml new file mode 100644 index 0000000..5bef1da --- /dev/null +++ b/claude-code-rust/Cargo.toml @@ -0,0 +1,104 @@ +[package] +name = "claude-code-rust" +version = "0.9.0" +edition = "2024" +rust-version = "1.88.0" +license = "Apache-2.0" +description = "A native Rust terminal interface for Claude Code" +repository = "https://github.com/srothgan/claude-code-rust" +keywords = ["cli", "tui", "claude", "ai", "terminal"] +categories = ["command-line-interface"] +default-run = "claude-rs" + +[lib] +name = "claude_code_rust" +path = "src/lib.rs" + +[[bin]] +name = "claude-rs" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.101" +arboard = "3.6.1" +async-trait = "0.1.89" +clap = { version = "4.5.57", features = ["derive"] } +crossterm = { version = "0.29.0", features = ["event-stream"] } +dirs = "6.0.0" +futures = "0.3.31" +ignore = "0.4.25" +notify-rust = "4.12.0" +pulldown-cmark = "0.13.3" +ratatui = { version = "0.30.0", features = ["unstable-rendered-line-info"] } +reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +similar = "2.7" +syntect = { version = "5.3.0", default-features = false, features = ["default-fancy"] } +thiserror = "2.0.18" +tokio = { version = "1.49.0", features = ["full"] } +tokio-util = { version = "0.7.18", features = ["compat"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } +tui-markdown = { version = "0.3.7" } +tui-textarea-2 = "0.10.2" +unicode-width = "0.2.2" +uuid = { version = "1.22.0", features = ["v4"] } +which = "8.0.2" + +[features] +perf = [] + +[profile.dev] +debug = "line-tables-only" +incremental = false + +[profile.dev.package."*"] +debug = false + +[profile.dev.build-override] +debug = false + +[profile.test] +debug = "line-tables-only" +incremental = false + +[profile.test.package."*"] +debug = false + +[profile.test.build-override] +debug = false + +[dev-dependencies] +pretty_assertions = "1.4" +tempfile = "3.25.0" + +[lints.clippy] +# Deny panic-prone operations in production code (tests exempted via clippy.toml) +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" + +# CLI tools legitimately print to stdout/stderr +print_stdout = "allow" +print_stderr = "allow" + +# Pedantic with noisy exceptions disabled +pedantic = { level = "warn", priority = -1 } +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" + +# Lint categories +correctness = { level = "deny", priority = -1 } +suspicious = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } + +# CLI-specific +exit = "deny" +shadow_same = "warn" +shadow_unrelated = "allow" +str_to_string = "warn" diff --git a/claude-code-rust/LICENSE b/claude-code-rust/LICENSE new file mode 100644 index 0000000..bc6be42 --- /dev/null +++ b/claude-code-rust/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Simon Peter Rothgang + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/claude-code-rust/README.md b/claude-code-rust/README.md new file mode 100644 index 0000000..d86271f --- /dev/null +++ b/claude-code-rust/README.md @@ -0,0 +1,75 @@ +# Claude Code Rust + +A native Rust terminal interface for Claude Code. Drop-in replacement for Anthropic's stock Node.js/React Ink TUI, built for performance and a better user experience. + +[![npm version](https://img.shields.io/npm/v/claude-code-rust)](https://www.npmjs.com/package/claude-code-rust) +[![npm downloads](https://img.shields.io/npm/dm/claude-code-rust)](https://www.npmjs.com/package/claude-code-rust) +[![CI](https://github.com/srothgan/claude-code-rust/actions/workflows/ci.yml/badge.svg)](https://github.com/srothgan/claude-code-rust/actions/workflows/ci.yml) +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) +[![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org/) + +## About + +Claude Code Rust replaces the stock Claude Code terminal interface with a native Rust binary built on [Ratatui](https://ratatui.rs/). It connects to the same Claude API through a local Agent SDK bridge. Core Claude Code functionality - tool calls, file editing, terminal commands, and permissions - works unchanged. + +## Requisites + +- Node.js 18+ (for the Agent SDK bridge) +- Existing Claude Code authentication (`~/.claude/config.json`) + +## Install + +### npm (global, recommended) + +```bash +npm install -g claude-code-rust +``` + +The published package installs a `claude-rs` command and fetches the matching +prebuilt release binary for your platform during install. + +If `claude-rs` resolves to an older global shim, ensure your npm global bin +directory comes first on `PATH` or remove the stale shim before retrying. + +## Usage + +```bash +claude-rs +``` + +## Why + +The stock Claude Code TUI runs on Node.js with React Ink. This causes real problems: + +- **Memory**: 200-400MB baseline vs ~20-50MB for a native binary +- **Startup**: 2-5 seconds vs under 100ms +- **Scrollback**: Broken virtual scrolling that loses history +- **Input latency**: Event queue delays on keystroke handling +- **Copy/paste**: Custom implementation instead of native terminal support + +Claude Code Rust fixes all of these by compiling to a single native binary with direct terminal control via Crossterm. + +## Architecture + +Three-layer design: + +**Presentation** (Rust/Ratatui) - Single binary with an async event loop (Tokio) handling keyboard input and bridge client events concurrently. Virtual-scrolled chat history with syntax-highlighted code blocks. + +**Agent SDK Bridge** (stdio JSON envelopes) - Spawns `agent-sdk/dist/bridge.js` as a child process and communicates via line-delimited JSON envelopes over stdin/stdout. Bidirectional streaming for user messages, tool updates, and permission requests. + +**Agent Runtime** (Anthropic Agent SDK) - The TypeScript bridge drives `@anthropic-ai/claude-agent-sdk`, which manages authentication, session/query lifecycle, and tool execution. + +## Status + +This project is pre-1.0 and under active development. See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get involved. + +## License + +This project is licensed under the [Apache License 2.0](LICENSE). +Apache-2.0 was chosen to keep usage and redistribution straightforward for individual users, downstream packagers, and commercial adopters. + +## Disclaimer + +This project is an unofficial terminal UI for Claude Code and is not affiliated with, endorsed by, or supported by Anthropic. +Use it at your own risk. +For official Claude documentation, see [https://claude.ai/docs](https://claude.ai/docs). diff --git a/claude-code-rust/SECURITY.md b/claude-code-rust/SECURITY.md new file mode 100644 index 0000000..2abdc9e --- /dev/null +++ b/claude-code-rust/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| latest | Yes | +| < latest | No (upgrade to latest) | + +## Reporting a Vulnerability + +**Please do NOT report security vulnerabilities through public GitHub issues.** + +Instead, use [GitHub Security Advisories](https://github.com/srothgan/claude-code-rust/security/advisories/new) +to report vulnerabilities privately. + +Please include: + +1. Description of the vulnerability +2. Steps to reproduce +3. Potential impact +4. Suggested fix (if any) + +## Response Timeline + +- **Acknowledgment**: Within 48 hours +- **Initial assessment**: Within 1 week +- **Fix and disclosure**: Coordinated with reporter, typically within 30 days + +## Scope + +This policy covers the `claude-rs` binary and its direct dependencies. Vulnerabilities +in the upstream Agent SDK (`@anthropic-ai/claude-agent-sdk`) or Claude API should be +reported to their respective maintainers. + +## Security Measures + +- Dependencies are audited weekly via `cargo audit` (automated in CI) +- Dependency updates are managed via Dependabot +- All PRs require CI checks including security audit diff --git a/claude-code-rust/agent-sdk/README.md b/claude-code-rust/agent-sdk/README.md new file mode 100644 index 0000000..ef8b329 --- /dev/null +++ b/claude-code-rust/agent-sdk/README.md @@ -0,0 +1,17 @@ +# claude-rs agent-sdk bridge + +Initial scaffold for the NDJSON stdio bridge that will connect Rust (`claude-code-rust`) with `@anthropic-ai/claude-agent-sdk`. + +## Local build + +```bash +npm install +npm run build +``` + +Build output is written to `dist/bridge.mjs`. + +## License + +This bridge is part of the `claude-code-rust` project and is licensed under +the Apache License 2.0. See the repository root [LICENSE](../LICENSE). diff --git a/claude-code-rust/agent-sdk/package-lock.json b/claude-code-rust/agent-sdk/package-lock.json new file mode 100644 index 0000000..c8cbd47 --- /dev/null +++ b/claude-code-rust/agent-sdk/package-lock.json @@ -0,0 +1,347 @@ +{ + "name": "@srothgan/claude-rs-agent-bridge", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@srothgan/claude-rs-agent-bridge", + "version": "0.0.1", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.74" + }, + "devDependencies": { + "@types/node": "25.3.3", + "typescript": "5.9.3" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.74", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.74.tgz", + "integrity": "sha512-S/SFSSbZHPL1HiQxAqCCxU3iHuE5nM+ir0OK1n0bZ+9hlVUH7OOn88AsV9s54E0c1kvH9YF4/foWH8J9kICsBw==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/node": { + "version": "25.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.3.6", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/claude-code-rust/agent-sdk/package.json b/claude-code-rust/agent-sdk/package.json new file mode 100644 index 0000000..d889392 --- /dev/null +++ b/claude-code-rust/agent-sdk/package.json @@ -0,0 +1,22 @@ +{ + "name": "@srothgan/claude-rs-agent-bridge", + "version": "0.0.1", + "private": true, + "type": "module", + "license": "Apache-2.0", + "description": "NDJSON bridge between claude-code-rust and the Claude Agent SDK", + "main": "dist/bridge.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/bridge.js", + "check": "npm run build", + "test": "npm run build && node --test dist/**/*.test.js" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.74" + }, + "devDependencies": { + "@types/node": "25.3.3", + "typescript": "5.9.3" + } +} diff --git a/claude-code-rust/agent-sdk/src/bridge.test.ts b/claude-code-rust/agent-sdk/src/bridge.test.ts new file mode 100644 index 0000000..bf2d6e7 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge.test.ts @@ -0,0 +1,1709 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + AsyncQueue, + CACHE_SPLIT_POLICY, + buildRateLimitUpdate, + buildQueryOptions, + canGenerateSessionTitle, + generatePersistedSessionTitle, + buildSessionMutationOptions, + buildSessionListOptions, + buildToolResultFields, + createToolCall, + handleTaskSystemMessage, + mapAvailableAgents, + mapAvailableModels, + mapSessionMessagesToUpdates, + mapSdkSessions, + agentSdkVersionCompatibilityError, + looksLikeAuthRequired, + normalizeToolResultText, + parseFastModeState, + parseRateLimitStatus, + normalizeToolKind, + parseCommandEnvelope, + permissionOptionsFromSuggestions, + permissionResultFromOutcome, + previewKilobyteLabel, + staleMcpAuthCandidates, + resolveInstalledAgentSdkVersion, + unwrapToolUseResult, +} from "./bridge.js"; +import type { SessionState } from "./bridge.js"; +import { emitToolProgressUpdate } from "./bridge/tool_calls.js"; +import { requestAskUserQuestionAnswers } from "./bridge/user_interaction.js"; + +function makeSessionState(): SessionState { + const input = new AsyncQueue(); + return { + sessionId: "session-1", + cwd: "C:/work", + model: "haiku", + availableModels: [], + mode: null, + fastModeState: "off", + query: {} as import("@anthropic-ai/claude-agent-sdk").Query, + input, + connected: true, + connectEvent: "connected", + toolCalls: new Map(), + taskToolUseIds: new Map(), + pendingPermissions: new Map(), + pendingQuestions: new Map(), + pendingElicitations: new Map(), + mcpStatusRevalidatedAt: new Map(), + authHintSent: false, + }; +} + +function captureBridgeEvents(run: () => void): Array> { + const writes: string[] = []; + const originalWrite = process.stdout.write; + (process.stdout.write as unknown as (...args: unknown[]) => boolean) = ( + chunk: unknown, + ): boolean => { + if (typeof chunk === "string") { + writes.push(chunk); + } else if (Buffer.isBuffer(chunk)) { + writes.push(chunk.toString("utf8")); + } else { + writes.push(String(chunk)); + } + return true; + }; + + try { + run(); + } finally { + process.stdout.write = originalWrite; + } + + return writes + .map((line) => line.trim()) + .filter((line) => line.startsWith("{")) + .flatMap((line) => { + try { + return [JSON.parse(line) as Record]; + } catch { + return []; + } + }); +} + +async function captureBridgeEventsAsync( + run: () => Promise, +): Promise>> { + const writes: string[] = []; + const originalWrite = process.stdout.write; + (process.stdout.write as unknown as (...args: unknown[]) => boolean) = ( + chunk: unknown, + ): boolean => { + if (typeof chunk === "string") { + writes.push(chunk); + } else if (Buffer.isBuffer(chunk)) { + writes.push(chunk.toString("utf8")); + } else { + writes.push(String(chunk)); + } + return true; + }; + + try { + await run(); + } finally { + process.stdout.write = originalWrite; + } + + return writes + .map((line) => line.trim()) + .filter((line) => line.startsWith("{")) + .flatMap((line) => { + try { + return [JSON.parse(line) as Record]; + } catch { + return []; + } + }); +} + +test("parseCommandEnvelope validates initialize command", () => { + const parsed = parseCommandEnvelope( + JSON.stringify({ + request_id: "req-1", + command: "initialize", + cwd: "C:/work", + }), + ); + assert.equal(parsed.requestId, "req-1"); + assert.equal(parsed.command.command, "initialize"); + if (parsed.command.command !== "initialize") { + throw new Error("unexpected command variant"); + } + assert.equal(parsed.command.cwd, "C:/work"); +}); + +test("parseCommandEnvelope validates resume_session command without cwd", () => { + const parsed = parseCommandEnvelope( + JSON.stringify({ + request_id: "req-2", + command: "resume_session", + session_id: "session-123", + launch_settings: { + language: "German", + settings: { + alwaysThinkingEnabled: true, + model: "haiku", + permissions: { defaultMode: "plan" }, + fastMode: false, + effortLevel: "high", + outputStyle: "Default", + spinnerTipsEnabled: true, + terminalProgressBarEnabled: true, + }, + agent_progress_summaries: true, + }, + }), + ); + assert.equal(parsed.requestId, "req-2"); + assert.equal(parsed.command.command, "resume_session"); + if (parsed.command.command !== "resume_session") { + throw new Error("unexpected command variant"); + } + assert.equal(parsed.command.session_id, "session-123"); + assert.equal(parsed.command.launch_settings.language, "German"); + assert.deepEqual(parsed.command.launch_settings.settings, { + alwaysThinkingEnabled: true, + model: "haiku", + permissions: { defaultMode: "plan" }, + fastMode: false, + effortLevel: "high", + outputStyle: "Default", + spinnerTipsEnabled: true, + terminalProgressBarEnabled: true, + }); + assert.equal(parsed.command.launch_settings.agent_progress_summaries, true); +}); + +test("parseCommandEnvelope validates rename_session command", () => { + const parsed = parseCommandEnvelope( + JSON.stringify({ + request_id: "req-rename", + command: "rename_session", + session_id: "session-123", + title: "Renamed session", + }), + ); + + assert.equal(parsed.requestId, "req-rename"); + assert.equal(parsed.command.command, "rename_session"); + if (parsed.command.command !== "rename_session") { + throw new Error("unexpected command variant"); + } + assert.equal(parsed.command.session_id, "session-123"); + assert.equal(parsed.command.title, "Renamed session"); +}); + +test("parseCommandEnvelope validates generate_session_title command", () => { + const parsed = parseCommandEnvelope( + JSON.stringify({ + request_id: "req-generate", + command: "generate_session_title", + session_id: "session-123", + description: "Current custom title", + }), + ); + + assert.equal(parsed.requestId, "req-generate"); + assert.equal(parsed.command.command, "generate_session_title"); + if (parsed.command.command !== "generate_session_title") { + throw new Error("unexpected command variant"); + } + assert.equal(parsed.command.session_id, "session-123"); + assert.equal(parsed.command.description, "Current custom title"); +}); + +test("parseCommandEnvelope validates mcp_toggle command", () => { + const parsed = parseCommandEnvelope( + JSON.stringify({ + request_id: "req-mcp-toggle", + command: "mcp_toggle", + session_id: "session-123", + server_name: "notion", + enabled: false, + }), + ); + + assert.equal(parsed.requestId, "req-mcp-toggle"); + assert.equal(parsed.command.command, "mcp_toggle"); + if (parsed.command.command !== "mcp_toggle") { + throw new Error("unexpected command variant"); + } + assert.equal(parsed.command.session_id, "session-123"); + assert.equal(parsed.command.server_name, "notion"); + assert.equal(parsed.command.enabled, false); +}); + +test("parseCommandEnvelope validates mcp_set_servers command", () => { + const parsed = parseCommandEnvelope( + JSON.stringify({ + request_id: "req-mcp-set", + command: "mcp_set_servers", + session_id: "session-123", + servers: { + notion: { + type: "http", + url: "https://mcp.notion.com/mcp", + headers: { + "X-Test": "1", + }, + }, + }, + }), + ); + + assert.equal(parsed.requestId, "req-mcp-set"); + assert.equal(parsed.command.command, "mcp_set_servers"); + if (parsed.command.command !== "mcp_set_servers") { + throw new Error("unexpected command variant"); + } + assert.equal(parsed.command.session_id, "session-123"); + assert.deepEqual(parsed.command.servers, { + notion: { + type: "http", + url: "https://mcp.notion.com/mcp", + headers: { + "X-Test": "1", + }, + }, + }); +}); + +test("staleMcpAuthCandidates selects previously connected servers that regressed to needs-auth", () => { + const candidates = staleMcpAuthCandidates( + [ + { + name: "supabase", + status: "needs-auth", + server_info: undefined, + error: undefined, + config: undefined, + scope: undefined, + tools: [], + }, + { + name: "notion", + status: "needs-auth", + server_info: undefined, + error: undefined, + config: undefined, + scope: undefined, + tools: [], + }, + ], + new Set(["supabase"]), + new Map(), + 10_000, + 1_000, + ); + + assert.deepEqual(candidates, ["supabase"]); +}); + +test("staleMcpAuthCandidates respects the revalidation cooldown", () => { + const candidates = staleMcpAuthCandidates( + [ + { + name: "supabase", + status: "needs-auth", + server_info: undefined, + error: undefined, + config: undefined, + scope: undefined, + tools: [], + }, + ], + new Set(["supabase"]), + new Map([["supabase", 9_500]]), + 10_000, + 1_000, + ); + + assert.deepEqual(candidates, []); +}); + +test("buildSessionMutationOptions scopes rename requests to the session cwd", () => { + assert.deepEqual(buildSessionMutationOptions("C:/worktree"), { dir: "C:/worktree" }); + assert.equal(buildSessionMutationOptions(undefined), undefined); +}); + +test("canGenerateSessionTitle detects supported query objects", () => { + const query = { + async generateSessionTitle(): Promise { + return "Generated"; + }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; + + assert.equal(canGenerateSessionTitle(query), true); + assert.equal(canGenerateSessionTitle({} as import("@anthropic-ai/claude-agent-sdk").Query), false); +}); + +test("generatePersistedSessionTitle calls sdk query with persist true", async () => { + const calls: Array<{ description: string; persist?: boolean }> = []; + const query = { + async generateSessionTitle( + description: string, + options?: { persist?: boolean }, + ): Promise { + calls.push({ description, persist: options?.persist }); + return "Generated title"; + }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; + + const title = await generatePersistedSessionTitle(query, "Current summary"); + + assert.equal(title, "Generated title"); + assert.deepEqual(calls, [{ description: "Current summary", persist: true }]); +}); + +test("buildQueryOptions maps launch settings into sdk query options", () => { + const input = new AsyncQueue(); + const options = buildQueryOptions({ + cwd: "C:/work", + launchSettings: { + language: "German", + settings: { + alwaysThinkingEnabled: true, + model: "haiku", + permissions: { defaultMode: "plan" }, + fastMode: false, + effortLevel: "medium", + outputStyle: "Default", + spinnerTipsEnabled: true, + terminalProgressBarEnabled: true, + }, + agent_progress_summaries: true, + }, + provisionalSessionId: "session-1", + input, + canUseTool: async () => ({ behavior: "deny", message: "not used" }), + enableSdkDebug: false, + enableSpawnDebug: false, + sessionIdForLogs: () => "session-1", + }); + + assert.deepEqual(options.settings, { + alwaysThinkingEnabled: true, + model: "haiku", + permissions: { defaultMode: "plan" }, + fastMode: false, + effortLevel: "medium", + outputStyle: "Default", + spinnerTipsEnabled: true, + terminalProgressBarEnabled: true, + }); + assert.deepEqual(options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: + "Always respond to the user in German unless the user explicitly asks for a different language. " + + "Keep code, shell commands, file paths, API names, tool names, and raw error text unchanged unless the user explicitly asks for translation.", + }); + assert.equal(options.model, "haiku"); + assert.equal(options.permissionMode, "plan"); + assert.equal("allowDangerouslySkipPermissions" in options, false); + assert.equal("thinking" in options, false); + assert.equal("effort" in options, false); + assert.equal(options.agentProgressSummaries, true); + assert.equal(options.sessionId, "session-1"); + assert.deepEqual(options.settingSources, ["user", "project", "local"]); + assert.deepEqual(options.toolConfig, { + askUserQuestion: { previewFormat: "markdown" }, + }); +}); + +test("buildQueryOptions forwards settings and maps startup model and permission mode", () => { + const input = new AsyncQueue(); + const options = buildQueryOptions({ + cwd: "C:/work", + launchSettings: { + settings: { + alwaysThinkingEnabled: false, + permissions: { defaultMode: "default" }, + fastMode: true, + effortLevel: "high", + outputStyle: "Learning", + spinnerTipsEnabled: false, + terminalProgressBarEnabled: false, + }, + }, + provisionalSessionId: "session-3", + input, + canUseTool: async () => ({ behavior: "deny", message: "not used" }), + enableSdkDebug: false, + enableSpawnDebug: false, + sessionIdForLogs: () => "session-3", + }); + + assert.deepEqual(options.settings, { + alwaysThinkingEnabled: false, + permissions: { defaultMode: "default" }, + fastMode: true, + effortLevel: "high", + outputStyle: "Learning", + spinnerTipsEnabled: false, + terminalProgressBarEnabled: false, + }); + assert.equal("model" in options, false); + assert.equal(options.permissionMode, "default"); + assert.equal("allowDangerouslySkipPermissions" in options, false); + assert.equal("thinking" in options, false); + assert.equal("effort" in options, false); +}); + +test("buildQueryOptions trims startup model before passing sdk option", () => { + const input = new AsyncQueue(); + const options = buildQueryOptions({ + cwd: "C:/work", + launchSettings: { + settings: { + model: " claude-opus-4-6 ", + permissions: { defaultMode: "plan" }, + }, + }, + provisionalSessionId: "session-model", + input, + canUseTool: async () => ({ behavior: "deny", message: "not used" }), + enableSdkDebug: false, + enableSpawnDebug: false, + sessionIdForLogs: () => "session-model", + }); + + assert.equal(options.model, "claude-opus-4-6"); + assert.equal(options.permissionMode, "plan"); +}); + +test("buildQueryOptions enables dangerous skip flag for bypass permissions startup mode", () => { + const input = new AsyncQueue(); + const options = buildQueryOptions({ + cwd: "C:/work", + launchSettings: { + settings: { + permissions: { defaultMode: "bypassPermissions" }, + }, + }, + provisionalSessionId: "session-4", + input, + canUseTool: async () => ({ behavior: "deny", message: "not used" }), + enableSdkDebug: false, + enableSpawnDebug: false, + sessionIdForLogs: () => "session-4", + }); + + assert.equal(options.permissionMode, "bypassPermissions"); + assert.equal(options.allowDangerouslySkipPermissions, true); +}); + +test("buildQueryOptions omits startup overrides for default logout path", () => { + const input = new AsyncQueue(); + const options = buildQueryOptions({ + cwd: "C:/work", + launchSettings: {}, + provisionalSessionId: "session-2", + input, + canUseTool: async () => ({ behavior: "deny", message: "not used" }), + enableSdkDebug: false, + enableSpawnDebug: false, + sessionIdForLogs: () => "session-2", + }); + + assert.equal("model" in options, false); + assert.equal("permissionMode" in options, false); + assert.equal("allowDangerouslySkipPermissions" in options, false); + assert.equal("systemPrompt" in options, false); + assert.equal("agentProgressSummaries" in options, false); +}); + +test("handleTaskSystemMessage prefers task_progress summary over fallback text", () => { + const session = makeSessionState(); + + const events = captureBridgeEvents(() => { + handleTaskSystemMessage(session, "task_started", { + task_id: "task-1", + tool_use_id: "tool-1", + description: "Initial task description", + }); + handleTaskSystemMessage(session, "task_progress", { + task_id: "task-1", + summary: "Analyzing authentication flow", + description: "Should not be shown", + last_tool_name: "Read", + }); + }); + + const lastEvent = events.at(-1); + assert.ok(lastEvent); + assert.equal(lastEvent.event, "session_update"); + assert.deepEqual(lastEvent.update, { + type: "tool_call_update", + tool_call_update: { + tool_call_id: "tool-1", + fields: { + status: "in_progress", + raw_output: "Analyzing authentication flow", + content: [ + { + type: "content", + content: { type: "text", text: "Analyzing authentication flow" }, + }, + ], + }, + }, + }); +}); + +test("handleTaskSystemMessage falls back to description and last tool when progress summary is absent", () => { + const session = makeSessionState(); + + const events = captureBridgeEvents(() => { + handleTaskSystemMessage(session, "task_started", { + task_id: "task-1", + tool_use_id: "tool-1", + description: "Initial task description", + }); + handleTaskSystemMessage(session, "task_progress", { + task_id: "task-1", + description: "Inspecting auth code", + last_tool_name: "Read", + }); + }); + + const lastEvent = events.at(-1); + assert.ok(lastEvent); + assert.equal(lastEvent.event, "session_update"); + assert.deepEqual(lastEvent.update, { + type: "tool_call_update", + tool_call_update: { + tool_call_id: "tool-1", + fields: { + status: "in_progress", + raw_output: "Inspecting auth code (last tool: Read)", + content: [ + { + type: "content", + content: { type: "text", text: "Inspecting auth code (last tool: Read)" }, + }, + ], + }, + }, + }); +}); + +test("handleTaskSystemMessage final summary replaces prior task content and finalizes status", () => { + const session = makeSessionState(); + + const events = captureBridgeEvents(() => { + handleTaskSystemMessage(session, "task_started", { + task_id: "task-1", + tool_use_id: "tool-1", + description: "Initial task description", + }); + handleTaskSystemMessage(session, "task_progress", { + task_id: "task-1", + summary: "Analyzing authentication flow", + description: "Should not be shown", + }); + handleTaskSystemMessage(session, "task_notification", { + task_id: "task-1", + status: "completed", + summary: "Found the auth bug and prepared the fix", + }); + }); + + const lastEvent = events.at(-1); + assert.ok(lastEvent); + assert.equal(lastEvent.event, "session_update"); + assert.deepEqual(lastEvent.update, { + type: "tool_call_update", + tool_call_update: { + tool_call_id: "tool-1", + fields: { + status: "completed", + raw_output: "Found the auth bug and prepared the fix", + content: [ + { + type: "content", + content: { type: "text", text: "Found the auth bug and prepared the fix" }, + }, + ], + }, + }, + }); + assert.equal(session.taskToolUseIds.has("task-1"), false); +}); + +test("emitToolProgressUpdate does not reopen completed tools", () => { + const session = makeSessionState(); + session.toolCalls.set("tool-1", { + tool_call_id: "tool-1", + title: "Bash", + kind: "execute", + status: "completed", + content: [], + locations: [], + meta: { claudeCode: { toolName: "Bash" } }, + }); + + const events = captureBridgeEvents(() => { + emitToolProgressUpdate(session, "tool-1", "Bash"); + }); + + assert.equal(events.length, 0); + assert.equal(session.toolCalls.get("tool-1")?.status, "completed"); +}); + +test("buildQueryOptions trims language before appending system prompt", () => { + const input = new AsyncQueue(); + const options = buildQueryOptions({ + cwd: "C:/work", + launchSettings: { + language: " German ", + }, + provisionalSessionId: "session-4", + input, + canUseTool: async () => ({ behavior: "deny", message: "not used" }), + enableSdkDebug: false, + enableSpawnDebug: false, + sessionIdForLogs: () => "session-4", + }); + + assert.deepEqual(options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: + "Always respond to the user in German unless the user explicitly asks for a different language. " + + "Keep code, shell commands, file paths, API names, tool names, and raw error text unchanged unless the user explicitly asks for translation.", + }); +}); + +test("parseCommandEnvelope rejects missing required fields", () => { + assert.throws( + () => parseCommandEnvelope(JSON.stringify({ command: "set_model", session_id: "s1" })), + /set_model\.model must be a string/, + ); +}); + +test("parseCommandEnvelope validates question_response command", () => { + const parsed = parseCommandEnvelope( + JSON.stringify({ + request_id: "req-question", + command: "question_response", + session_id: "session-1", + tool_call_id: "tool-1", + outcome: { + outcome: "answered", + selected_option_ids: ["question_0", "question_2"], + annotation: { + preview: "Rendered preview", + notes: "User note", + }, + }, + }), + ); + + assert.equal(parsed.requestId, "req-question"); + assert.equal(parsed.command.command, "question_response"); + if (parsed.command.command !== "question_response") { + throw new Error("unexpected command variant"); + } + assert.deepEqual(parsed.command.outcome, { + outcome: "answered", + selected_option_ids: ["question_0", "question_2"], + annotation: { + preview: "Rendered preview", + notes: "User note", + }, + }); +}); + +test("requestAskUserQuestionAnswers preserves previews and annotations in updated input", async () => { + const session = makeSessionState(); + const baseToolCall = { + tool_call_id: "tool-question", + title: "AskUserQuestion", + kind: "other", + status: "in_progress", + content: [] as Array, + locations: [] as Array, + meta: { claudeCode: { toolName: "AskUserQuestion" } }, + }; + + const events = await captureBridgeEventsAsync(async () => { + const resultPromise = requestAskUserQuestionAnswers( + session, + "tool-question", + { + questions: [ + { + question: "Pick deployment target", + header: "Target", + multiSelect: true, + options: [ + { + label: "Staging", + description: "Low-risk validation", + preview: "Deploy to staging first.", + }, + { + label: "Production", + description: "Customer-facing rollout", + preview: "Deploy to production after approval.", + }, + ], + }, + ], + }, + baseToolCall, + ); + + await new Promise((resolve) => setImmediate(resolve)); + const pending = session.pendingQuestions.get("tool-question"); + assert.ok(pending, "expected pending question"); + pending.onOutcome({ + outcome: "answered", + selected_option_ids: ["question_0", "question_1"], + annotation: { + notes: "Roll out in both environments", + }, + }); + + const result = await resultPromise; + assert.equal(result.behavior, "allow"); + if (result.behavior !== "allow") { + throw new Error("expected allow result"); + } + assert.deepEqual(result.updatedInput, { + questions: [ + { + question: "Pick deployment target", + header: "Target", + multiSelect: true, + options: [ + { + label: "Staging", + description: "Low-risk validation", + preview: "Deploy to staging first.", + }, + { + label: "Production", + description: "Customer-facing rollout", + preview: "Deploy to production after approval.", + }, + ], + }, + ], + answers: { + "Pick deployment target": "Staging, Production", + }, + annotations: { + "Pick deployment target": { + preview: "Deploy to staging first.\n\nDeploy to production after approval.", + notes: "Roll out in both environments", + }, + }, + }); + }); + + const questionEvent = events.find((event) => event.event === "question_request"); + assert.ok(questionEvent, "expected question request event"); + assert.deepEqual(questionEvent.request, { + tool_call: { + tool_call_id: "tool-question", + title: "Pick deployment target", + kind: "other", + status: "in_progress", + content: [], + locations: [], + meta: { claudeCode: { toolName: "AskUserQuestion" } }, + raw_input: { + prompt: { + question: "Pick deployment target", + header: "Target", + multi_select: true, + options: [ + { + option_id: "question_0", + label: "Staging", + description: "Low-risk validation", + preview: "Deploy to staging first.", + }, + { + option_id: "question_1", + label: "Production", + description: "Customer-facing rollout", + preview: "Deploy to production after approval.", + }, + ], + }, + question_index: 0, + total_questions: 1, + }, + }, + prompt: { + question: "Pick deployment target", + header: "Target", + multi_select: true, + options: [ + { + option_id: "question_0", + label: "Staging", + description: "Low-risk validation", + preview: "Deploy to staging first.", + }, + { + option_id: "question_1", + label: "Production", + description: "Customer-facing rollout", + preview: "Deploy to production after approval.", + }, + ], + }, + question_index: 0, + total_questions: 1, + }); +}); + +test("normalizeToolKind maps known tool names", () => { + assert.equal(normalizeToolKind("Bash"), "execute"); + assert.equal(normalizeToolKind("Delete"), "delete"); + assert.equal(normalizeToolKind("Move"), "move"); + assert.equal(normalizeToolKind("Task"), "think"); + assert.equal(normalizeToolKind("Agent"), "think"); + assert.equal(normalizeToolKind("ExitPlanMode"), "switch_mode"); + assert.equal(normalizeToolKind("TodoWrite"), "other"); +}); + +test("parseFastModeState accepts known values and rejects unknown values", () => { + assert.equal(parseFastModeState("off"), "off"); + assert.equal(parseFastModeState("cooldown"), "cooldown"); + assert.equal(parseFastModeState("on"), "on"); + assert.equal(parseFastModeState("CD"), null); + assert.equal(parseFastModeState(undefined), null); +}); + +test("parseRateLimitStatus accepts known values and rejects unknown values", () => { + assert.equal(parseRateLimitStatus("allowed"), "allowed"); + assert.equal(parseRateLimitStatus("allowed_warning"), "allowed_warning"); + assert.equal(parseRateLimitStatus("rejected"), "rejected"); + assert.equal(parseRateLimitStatus("warn"), null); + assert.equal(parseRateLimitStatus(undefined), null); +}); + +test("buildRateLimitUpdate maps SDK fields to wire shape", () => { + const update = buildRateLimitUpdate({ + status: "allowed_warning", + resetsAt: 1_741_280_000, + utilization: 0.92, + rateLimitType: "five_hour", + overageStatus: "rejected", + overageResetsAt: 1_741_280_600, + overageDisabledReason: "out_of_credits", + isUsingOverage: false, + surpassedThreshold: 0.9, + }); + + assert.deepEqual(update, { + type: "rate_limit_update", + status: "allowed_warning", + resets_at: 1_741_280_000, + utilization: 0.92, + rate_limit_type: "five_hour", + overage_status: "rejected", + overage_resets_at: 1_741_280_600, + overage_disabled_reason: "out_of_credits", + is_using_overage: false, + surpassed_threshold: 0.9, + }); +}); + +test("buildRateLimitUpdate rejects invalid payloads", () => { + assert.equal(buildRateLimitUpdate(null), null); + assert.equal(buildRateLimitUpdate({}), null); + assert.equal(buildRateLimitUpdate({ status: "warning" }), null); + assert.deepEqual( + buildRateLimitUpdate({ + status: "rejected", + overageStatus: "bad_status", + }), + { type: "rate_limit_update", status: "rejected" }, + ); +}); + +test("mapAvailableAgents normalizes and deduplicates agents", () => { + const agents = mapAvailableAgents([ + { name: "reviewer", description: "", model: "" }, + { name: "reviewer", description: "Reviews code", model: "haiku" }, + { name: "explore", description: "Explore codebase", model: "sonnet" }, + { name: " ", description: "ignored" }, + {}, + ]); + + assert.deepEqual(agents, [ + { name: "explore", description: "Explore codebase", model: "sonnet" }, + { name: "reviewer", description: "Reviews code", model: "haiku" }, + ]); +}); + +test("mapAvailableAgents rejects non-array payload", () => { + assert.deepEqual(mapAvailableAgents(null), []); + assert.deepEqual(mapAvailableAgents({}), []); +}); + +test("createToolCall builds edit diff content", () => { + const toolCall = createToolCall("tc-1", "Edit", { + file_path: "src/main.rs", + old_string: "old", + new_string: "new", + }); + assert.equal(toolCall.kind, "edit"); + assert.equal(toolCall.content.length, 1); + assert.deepEqual(toolCall.content[0], { + type: "diff", + old_path: "src/main.rs", + new_path: "src/main.rs", + old: "old", + new: "new", + }); + assert.deepEqual(toolCall.meta, { claudeCode: { toolName: "Edit" } }); +}); + +test("createToolCall builds write preview diff content", () => { + const toolCall = createToolCall("tc-w", "Write", { + file_path: "src/new-file.ts", + content: "export const x = 1;\n", + }); + assert.equal(toolCall.kind, "edit"); + assert.deepEqual(toolCall.content, [ + { + type: "diff", + old_path: "src/new-file.ts", + new_path: "src/new-file.ts", + old: "", + new: "export const x = 1;\n", + }, + ]); +}); + +test("createToolCall includes glob and webfetch context in title", () => { + const glob = createToolCall("tc-g", "Glob", { pattern: "**/*.md", path: "notes" }); + assert.equal(glob.title, "Glob **/*.md in notes"); + + const fetch = createToolCall("tc-f", "WebFetch", { url: "https://example.com" }); + assert.equal(fetch.title, "WebFetch https://example.com"); +}); + +test("buildToolResultFields extracts plain-text output", () => { + const fields = buildToolResultFields(false, [{ text: "line 1" }, { text: "line 2" }]); + assert.equal(fields.status, "completed"); + assert.equal(fields.raw_output, "line 1\nline 2"); + assert.deepEqual(fields.content, [ + { type: "content", content: { type: "text", text: "line 1\nline 2" } }, + ]); +}); + +test("normalizeToolResultText collapses persisted-output payload to first meaningful line", () => { + const normalized = normalizeToolResultText(` + + │ Output too large (132.5KB). Full output saved to: C:\\tmp\\tool-results\\bbf63b9.txt + │ + │ Preview (first 2KB): + │ + │ {"huge":"payload"} + │ ... + │ +`); + assert.equal(normalized, "Output too large (132.5KB). Full output saved to: C:\\tmp\\tool-results\\bbf63b9.txt"); +}); + +test("normalizeToolResultText does not sanitize non-error output", () => { + const text = + "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."; + assert.equal(normalizeToolResultText(text), text); +}); + +test("normalizeToolResultText sanitizes exact SDK rejection payloads for errors", () => { + const cancelledText = + "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."; + assert.equal(normalizeToolResultText(cancelledText, true), "Cancelled by user."); + + const deniedText = + "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). Try a different approach or report the limitation to complete your task."; + assert.equal(normalizeToolResultText(deniedText, true), "Permission denied."); +}); + +test("normalizeToolResultText sanitizes SDK rejection prefixes with user follow-up", () => { + const cancelledWithUserMessage = + "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:\nPlease skip this"; + assert.equal(normalizeToolResultText(cancelledWithUserMessage, true), "Cancelled by user."); + + const deniedWithUserMessage = + "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). The user said:\nNot now"; + assert.equal(normalizeToolResultText(deniedWithUserMessage, true), "Permission denied."); +}); + +test("normalizeToolResultText does not sanitize substring matches in error output", () => { + const bashOutput = "grep output: doesn't want to proceed with this tool use"; + assert.equal(normalizeToolResultText(bashOutput, true), bashOutput); +}); + +test("cache split policy defaults stay aligned with UI thresholds", () => { + assert.equal(CACHE_SPLIT_POLICY.softLimitBytes, 1536); + assert.equal(CACHE_SPLIT_POLICY.hardLimitBytes, 4096); + assert.equal(CACHE_SPLIT_POLICY.previewLimitBytes, 2048); + assert.equal(previewKilobyteLabel(CACHE_SPLIT_POLICY), "2KB"); +}); + +test("buildToolResultFields uses normalized persisted-output text", () => { + const fields = buildToolResultFields( + false, + ` + │ Output too large (14KB). Full output saved to: C:\\tmp\\tool-results\\x.txt + │ + │ Preview (first 2KB): + │ {"k":"v"} + │ `, + ); + assert.equal(fields.raw_output, "Output too large (14KB). Full output saved to: C:\\tmp\\tool-results\\x.txt"); + assert.deepEqual(fields.content, [ + { + type: "content", + content: { + type: "text", + text: "Output too large (14KB). Full output saved to: C:\\tmp\\tool-results\\x.txt", + }, + }, + ]); +}); + +test("buildToolResultFields sanitizes SDK rejection text only for failed results", () => { + const sdkRejectionText = + "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."; + + const successFields = buildToolResultFields(false, sdkRejectionText); + assert.equal(successFields.raw_output, sdkRejectionText); + + const errorFields = buildToolResultFields(true, sdkRejectionText); + assert.equal(errorFields.raw_output, "Cancelled by user."); +}); + +test("buildToolResultFields maps structured Write output to diff content", () => { + const base = createToolCall("tc-w", "Write", { + file_path: "src/main.ts", + content: "new", + }); + const fields = buildToolResultFields( + false, + { + type: "update", + filePath: "src/main.ts", + content: "new", + originalFile: "old", + structuredPatch: [], + gitDiff: { + repository: "acme/project", + }, + }, + base, + ); + assert.equal(fields.status, "completed"); + assert.deepEqual(fields.content, [ + { + type: "diff", + old_path: "src/main.ts", + new_path: "src/main.ts", + old: "old", + new: "new", + repository: "acme/project", + }, + ]); +}); + +test("buildToolResultFields preserves Edit diff content from input and structured repository", () => { + const base = createToolCall("tc-e", "Edit", { + file_path: "src/main.ts", + old_string: "old", + new_string: "new", + }); + const fields = buildToolResultFields( + false, + [{ text: "Updated successfully" }], + base, + { + result: { + filePath: "src/main.ts", + gitDiff: { + repository: "acme/project", + }, + }, + }, + ); + assert.equal(fields.status, "completed"); + assert.deepEqual(fields.content, [ + { + type: "diff", + old_path: "src/main.ts", + new_path: "src/main.ts", + old: "old", + new: "new", + repository: "acme/project", + }, + ]); +}); + +test("buildToolResultFields prefers structured Bash stdout over token-saver output", () => { + const base = createToolCall("tc-bash", "Bash", { command: "npm test" }); + const fields = buildToolResultFields( + false, + { + stdout: "real stdout", + stderr: "", + interrupted: false, + tokenSaverOutput: "compressed output for model", + }, + base, + { + result: { + stdout: "real stdout", + stderr: "", + interrupted: false, + tokenSaverOutput: "compressed output for model", + }, + }, + ); + + assert.equal(fields.raw_output, "real stdout"); + assert.deepEqual(fields.output_metadata, { + bash: { + token_saver_active: true, + }, + }); +}); + +test("buildToolResultFields adds Bash auto-backgrounded metadata and message", () => { + const base = createToolCall("tc-bash-bg", "Bash", { command: "npm run watch" }); + const fields = buildToolResultFields( + false, + { + stdout: "", + stderr: "", + interrupted: false, + backgroundTaskId: "task-42", + assistantAutoBackgrounded: true, + }, + base, + { + result: { + stdout: "", + stderr: "", + interrupted: false, + backgroundTaskId: "task-42", + assistantAutoBackgrounded: true, + }, + }, + ); + + assert.equal( + fields.raw_output, + "Command was auto-backgrounded by assistant mode with ID: task-42.", + ); + assert.deepEqual(fields.output_metadata, { + bash: { + assistant_auto_backgrounded: true, + }, + }); +}); + +test("buildToolResultFields maps structured ReadMcpResource output to typed resource content", () => { + const base = createToolCall("tc-mcp", "ReadMcpResource", { + server: "docs", + uri: "file://manual.pdf", + }); + const fields = buildToolResultFields( + false, + { + contents: [ + { + uri: "file://manual.pdf", + mimeType: "application/pdf", + text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf", + blobSavedTo: "C:\\tmp\\manual.pdf", + }, + ], + }, + base, + { + result: { + contents: [ + { + uri: "file://manual.pdf", + mimeType: "application/pdf", + text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf", + blobSavedTo: "C:\\tmp\\manual.pdf", + }, + ], + }, + }, + ); + + assert.equal(fields.status, "completed"); + assert.deepEqual(fields.content, [ + { + type: "mcp_resource", + uri: "file://manual.pdf", + mime_type: "application/pdf", + text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf", + blob_saved_to: "C:\\tmp\\manual.pdf", + }, + ]); +}); + +test("buildToolResultFields restores ReadMcpResource blob paths from transcript JSON text", () => { + const base = createToolCall("tc-mcp-history", "ReadMcpResource", { + server: "docs", + uri: "file://manual.pdf", + }); + const transcriptJson = JSON.stringify({ + contents: [ + { + uri: "file://manual.pdf", + mimeType: "application/pdf", + text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf", + blobSavedTo: "C:\\tmp\\manual.pdf", + }, + ], + }); + const fields = buildToolResultFields(false, transcriptJson, base, { + type: "tool_result", + tool_use_id: "tc-mcp-history", + content: transcriptJson, + }); + + assert.deepEqual(fields.content, [ + { + type: "mcp_resource", + uri: "file://manual.pdf", + mime_type: "application/pdf", + text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf", + blob_saved_to: "C:\\tmp\\manual.pdf", + }, + ]); +}); + +test("unwrapToolUseResult extracts error/content payload", () => { + const parsed = unwrapToolUseResult({ + is_error: true, + content: [{ text: "failure output" }], + }); + assert.equal(parsed.isError, true); + assert.deepEqual(parsed.content, [{ text: "failure output" }]); +}); + +test("permissionResultFromOutcome maps selected and cancelled outcomes", () => { + const allow = permissionResultFromOutcome( + { outcome: "selected", option_id: "allow_always" }, + "tool-1", + { command: "echo test" }, + [], + ); + assert.equal(allow.behavior, "allow"); + if (allow.behavior === "allow") { + assert.deepEqual(allow.updatedInput, { command: "echo test" }); + } + + const deny = permissionResultFromOutcome( + { outcome: "selected", option_id: "reject_once" }, + "tool-1", + { command: "echo test" }, + ); + assert.equal(deny.behavior, "deny"); + assert.match(String(deny.message), /Permission denied/); + + const cancelled = permissionResultFromOutcome( + { outcome: "cancelled" }, + "tool-1", + { command: "echo test" }, + ); + assert.equal(cancelled.behavior, "deny"); + assert.match(String(cancelled.message), /cancelled/i); +}); + +test("permissionOptionsFromSuggestions uses session label when only session scope is suggested", () => { + const options = permissionOptionsFromSuggestions([ + { + type: "setMode", + mode: "acceptEdits", + destination: "session", + }, + ]); + assert.deepEqual(options, [ + { option_id: "allow_once", name: "Allow once", kind: "allow_once" }, + { option_id: "allow_session", name: "Allow for session", kind: "allow_session" }, + { option_id: "reject_once", name: "Deny", kind: "reject_once" }, + ]); +}); + +test("permissionOptionsFromSuggestions uses persistent label when settings scope is suggested", () => { + const options = permissionOptionsFromSuggestions([ + { + type: "addRules", + behavior: "allow", + destination: "localSettings", + rules: [{ toolName: "Bash", ruleContent: "npm install" }], + }, + ]); + assert.deepEqual(options, [ + { option_id: "allow_once", name: "Allow once", kind: "allow_once" }, + { option_id: "allow_always", name: "Always allow", kind: "allow_always" }, + { option_id: "reject_once", name: "Deny", kind: "reject_once" }, + ]); +}); + +test("permissionResultFromOutcome keeps Bash allow_always suggestions unchanged", () => { + const allow = permissionResultFromOutcome( + { outcome: "selected", option_id: "allow_always" }, + "tool-1", + { command: "npm install" }, + [ + { + type: "addRules", + behavior: "allow", + destination: "localSettings", + rules: [ + { toolName: "Bash", ruleContent: "npm install" }, + { toolName: "WebFetch", ruleContent: "https://example.com" }, + { toolName: "Bash", ruleContent: "dir /B" }, + ], + }, + ], + "Bash", + ); + + assert.equal(allow.behavior, "allow"); + if (allow.behavior !== "allow") { + throw new Error("expected allow permission result"); + } + assert.deepEqual(allow.updatedPermissions, [ + { + type: "addRules", + behavior: "allow", + destination: "localSettings", + rules: [ + { toolName: "Bash", ruleContent: "npm install" }, + { toolName: "WebFetch", ruleContent: "https://example.com" }, + { toolName: "Bash", ruleContent: "dir /B" }, + ], + }, + ]); +}); + +test("permissionResultFromOutcome keeps Write allow_session suggestions unchanged", () => { + const suggestions = [ + { + type: "addRules" as const, + behavior: "allow" as const, + destination: "session" as const, + rules: [{ toolName: "Write", ruleContent: "C:\\work\\foo.txt" }], + }, + ]; + const allow = permissionResultFromOutcome( + { outcome: "selected", option_id: "allow_session" }, + "tool-2", + { file_path: "C:\\work\\foo.txt" }, + suggestions, + "Write", + ); + + assert.equal(allow.behavior, "allow"); + if (allow.behavior !== "allow") { + throw new Error("expected allow permission result"); + } + assert.deepEqual(allow.updatedPermissions, suggestions); +}); + +test("permissionResultFromOutcome falls back to session tool rule for allow_session when suggestions are missing", () => { + const allow = permissionResultFromOutcome( + { outcome: "selected", option_id: "allow_session" }, + "tool-3", + { file_path: "C:\\work\\bar.txt" }, + undefined, + "Write", + ); + + assert.equal(allow.behavior, "allow"); + if (allow.behavior !== "allow") { + throw new Error("expected allow permission result"); + } + assert.deepEqual(allow.updatedPermissions, [ + { + type: "addRules", + behavior: "allow", + destination: "session", + rules: [{ toolName: "Write" }], + }, + ]); +}); + +test("permissionResultFromOutcome falls back to localSettings rule for allow_always when only session suggestions exist", () => { + const allow = permissionResultFromOutcome( + { outcome: "selected", option_id: "allow_always" }, + "tool-4", + { file_path: "C:\\work\\baz.txt" }, + [ + { + type: "addRules", + behavior: "allow", + destination: "session", + rules: [{ toolName: "Write", ruleContent: "C:\\work\\baz.txt" }], + }, + ], + "Write", + ); + + assert.equal(allow.behavior, "allow"); + if (allow.behavior !== "allow") { + throw new Error("expected allow permission result"); + } + assert.deepEqual(allow.updatedPermissions, [ + { + type: "addRules", + rules: [{ toolName: "Write" }], + behavior: "allow", + destination: "localSettings", + }, + ]); +}); + +test("looksLikeAuthRequired detects login hints", () => { + assert.equal(looksLikeAuthRequired("Please run /login to continue"), true); + assert.equal(looksLikeAuthRequired("normal tool output"), false); +}); + +test("agent sdk version compatibility check matches pinned version", () => { + assert.equal(resolveInstalledAgentSdkVersion(), "0.2.74"); + assert.equal(agentSdkVersionCompatibilityError(), undefined); +}); + +test("mapSessionMessagesToUpdates maps message content blocks", () => { + const updates = mapSessionMessagesToUpdates([ + { + type: "user", + uuid: "u1", + session_id: "s1", + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "text", text: "Top-level user prompt" }], + }, + }, + { + type: "assistant", + uuid: "a1", + session_id: "s1", + parent_tool_use_id: null, + message: { + id: "msg-1", + role: "assistant", + content: [ + { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "echo hello" } }, + { type: "text", text: "Nested assistant final" }, + ], + usage: { + input_tokens: 11, + output_tokens: 7, + cache_read_input_tokens: 5, + cache_creation_input_tokens: 3, + }, + }, + }, + { + type: "user", + uuid: "u2", + session_id: "s1", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "ok", + is_error: false, + }, + ], + }, + }, + ]); + + const variantCounts = new Map(); + for (const update of updates) { + variantCounts.set(update.type, (variantCounts.get(update.type) ?? 0) + 1); + } + + assert.equal(variantCounts.get("user_message_chunk"), 1); + assert.equal(variantCounts.get("agent_message_chunk"), 1); + assert.equal(variantCounts.get("tool_call"), 1); + assert.equal(variantCounts.get("tool_call_update"), 1); +}); + +test("mapSessionMessagesToUpdates ignores unsupported records", () => { + const updates = mapSessionMessagesToUpdates([ + { + type: "user", + uuid: "u1", + session_id: "s1", + parent_tool_use_id: null, + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "h" }], + }, + }, + ]); + assert.equal(updates.length, 0); +}); + +test("mapSdkSessions normalizes and sorts sessions", () => { + const mapped = mapSdkSessions([ + { + sessionId: "older", + summary: " Older summary ", + lastModified: 100, + fileSize: 10, + cwd: "C:/work", + }, + { + sessionId: "latest", + summary: "", + lastModified: 200, + fileSize: 20, + customTitle: "Custom title", + gitBranch: "main", + firstPrompt: "hello", + }, + ]); + + assert.deepEqual(mapped, [ + { + session_id: "latest", + summary: "Custom title", + last_modified_ms: 200, + file_size_bytes: 20, + git_branch: "main", + custom_title: "Custom title", + first_prompt: "hello", + }, + { + session_id: "older", + summary: "Older summary", + last_modified_ms: 100, + file_size_bytes: 10, + cwd: "C:/work", + }, + ]); +}); + +test("buildSessionListOptions scopes repo-local listings to worktrees", () => { + assert.deepEqual(buildSessionListOptions("C:/repo"), { + dir: "C:/repo", + includeWorktrees: true, + limit: 50, + }); + assert.deepEqual(buildSessionListOptions(undefined), { + limit: 50, + }); +}); + +test("buildToolResultFields extracts ExitPlanMode ultraplan metadata from structured results", () => { + const base = createToolCall("tc-plan", "ExitPlanMode", {}); + const fields = buildToolResultFields( + false, + [{ text: "Plan ready for approval" }], + base, + { + result: { + plan: "Plan contents", + isUltraplan: true, + }, + }, + ); + + assert.deepEqual(fields.output_metadata, { + exit_plan_mode: { + is_ultraplan: true, + }, + }); +}); + +test("buildToolResultFields extracts TodoWrite verification metadata from structured results", () => { + const base = createToolCall("tc-todo", "TodoWrite", { + todos: [{ content: "Verify changes", status: "pending", activeForm: "Verifying changes" }], + }); + const fields = buildToolResultFields( + false, + [{ text: "Todos have been modified successfully." }], + base, + { + data: { + oldTodos: [], + newTodos: [], + verificationNudgeNeeded: true, + }, + }, + ); + + assert.deepEqual(fields.output_metadata, { + todo_write: { + verification_nudge_needed: true, + }, + }); +}); + +test("mapAvailableModels preserves optional fast and auto mode metadata", () => { + const mapped = mapAvailableModels([ + { + value: "sonnet", + displayName: "Claude Sonnet", + description: "Balanced model", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + supportsAdaptiveThinking: true, + supportsFastMode: true, + supportsAutoMode: false, + }, + { + value: "haiku", + displayName: "Claude Haiku", + description: "Fast model", + supportsEffort: false, + }, + ]); + + assert.deepEqual(mapped, [ + { + id: "sonnet", + display_name: "Claude Sonnet", + description: "Balanced model", + supports_effort: true, + supported_effort_levels: ["low", "medium", "high"], + supports_adaptive_thinking: true, + supports_fast_mode: true, + supports_auto_mode: false, + }, + { + id: "haiku", + display_name: "Claude Haiku", + description: "Fast model", + supports_effort: false, + supported_effort_levels: [], + }, + ]); +}); diff --git a/claude-code-rust/agent-sdk/src/bridge.ts b/claude-code-rust/agent-sdk/src/bridge.ts new file mode 100644 index 0000000..0451e9a --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge.ts @@ -0,0 +1,502 @@ +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import readline from "node:readline"; +import { pathToFileURL } from "node:url"; +import { + getSessionMessages, + listSessions, + renameSession, +} from "@anthropic-ai/claude-agent-sdk"; +import type { BridgeCommand } from "./types.js"; +import { parseCommandEnvelope, toPermissionMode, buildModeState } from "./bridge/commands.js"; +import { + writeEvent, + failConnection, + slashError, + emitSessionUpdate, + emitSessionsList, + currentSessionListOptions, + setSessionListingDir, +} from "./bridge/events.js"; +import { textFromPrompt } from "./bridge/message_handlers.js"; +import { + sessions, + sessionById, + createSession, + closeSession, + closeAllSessions, + handleElicitationResponse, + handlePermissionResponse, + handleQuestionResponse, +} from "./bridge/session_lifecycle.js"; +import { mapSessionMessagesToUpdates } from "./bridge/history.js"; +import { + MCP_STALE_STATUS_REVALIDATION_COOLDOWN_MS, + handleMcpAuthenticateCommand, + handleMcpClearAuthCommand, + handleMcpOauthCallbackUrlCommand, + handleMcpReconnectCommand, + handleMcpSetServersCommand, + handleMcpStatusCommand, + handleMcpToggleCommand, + staleMcpAuthCandidates, +} from "./bridge/mcp.js"; + +// Re-exports: all symbols that tests and external consumers import from bridge.js. +export { AsyncQueue, logPermissionDebug } from "./bridge/shared.js"; +export { asRecordOrNull } from "./bridge/shared.js"; +export { CACHE_SPLIT_POLICY, previewKilobyteLabel } from "./bridge/cache_policy.js"; +export { + buildToolResultFields, + createToolCall, + normalizeToolKind, + normalizeToolResultText, + unwrapToolUseResult, +} from "./bridge/tooling.js"; +export { looksLikeAuthRequired } from "./bridge/auth.js"; +export { parseCommandEnvelope } from "./bridge/commands.js"; +export { buildSessionListOptions } from "./bridge/events.js"; +export { + permissionOptionsFromSuggestions, + permissionResultFromOutcome, +} from "./bridge/permissions.js"; +export { + mapSessionMessagesToUpdates, + mapSdkSessions, +} from "./bridge/history.js"; +export { handleTaskSystemMessage } from "./bridge/message_handlers.js"; +export { mapAvailableAgents } from "./bridge/agents.js"; +export { buildQueryOptions, mapAvailableModels } from "./bridge/session_lifecycle.js"; +export { + parseFastModeState, + parseRateLimitStatus, + buildRateLimitUpdate, +} from "./bridge/state_parsing.js"; +export { MCP_STALE_STATUS_REVALIDATION_COOLDOWN_MS, staleMcpAuthCandidates }; +export type { + SessionState, + ConnectEventKind, + PendingPermission, + PendingQuestion, +} from "./bridge/session_lifecycle.js"; + +export function buildSessionMutationOptions( + cwd?: string, +): import("@anthropic-ai/claude-agent-sdk").SessionMutationOptions | undefined { + return cwd ? { dir: cwd } : undefined; +} + +type SessionTitleGeneratingQuery = import("@anthropic-ai/claude-agent-sdk").Query & { + generateSessionTitle: ( + description: string, + options?: { persist?: boolean }, + ) => Promise; +}; + +export function canGenerateSessionTitle( + query: import("@anthropic-ai/claude-agent-sdk").Query, +): query is SessionTitleGeneratingQuery { + return typeof (query as { generateSessionTitle?: unknown }).generateSessionTitle === "function"; +} + +export async function generatePersistedSessionTitle( + query: import("@anthropic-ai/claude-agent-sdk").Query, + description: string, +): Promise { + if (!canGenerateSessionTitle(query)) { + throw new Error("SDK query does not support generateSessionTitle"); + } + const title = await query.generateSessionTitle(description, { persist: true }); + if (typeof title !== "string" || title.trim().length === 0) { + throw new Error("SDK did not return a generated session title"); + } + return title; +} + +const EXPECTED_AGENT_SDK_VERSION = "0.2.74"; +const require = createRequire(import.meta.url); + +export function resolveInstalledAgentSdkVersion(): string | undefined { + try { + const entryPath = require.resolve("@anthropic-ai/claude-agent-sdk"); + const packageJsonPath = join(dirname(entryPath), "package.json"); + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; + return typeof pkg.version === "string" ? pkg.version : undefined; + } catch { + return undefined; + } +} + +export function agentSdkVersionCompatibilityError(): string | undefined { + const installed = resolveInstalledAgentSdkVersion(); + if (!installed) { + return ( + `Agent SDK version check failed: unable to resolve installed ` + + `@anthropic-ai/claude-agent-sdk package.json (expected ${EXPECTED_AGENT_SDK_VERSION}).` + ); + } + if (installed === EXPECTED_AGENT_SDK_VERSION) { + return undefined; + } + return ( + `Unsupported @anthropic-ai/claude-agent-sdk version: expected ${EXPECTED_AGENT_SDK_VERSION}, ` + + `found ${installed}.` + ); +} + +async function handleCommand(command: BridgeCommand, requestId?: string): Promise { + const sdkVersionError = agentSdkVersionCompatibilityError(); + if (sdkVersionError && command.command !== "initialize" && command.command !== "shutdown") { + failConnection(sdkVersionError, requestId); + return; + } + + switch (command.command) { + case "initialize": + if (sdkVersionError) { + failConnection(sdkVersionError, requestId); + return; + } + setSessionListingDir(command.cwd); + writeEvent( + { + event: "initialized", + result: { + agent_name: "claude-rs-agent-bridge", + agent_version: "0.1.0", + auth_methods: [ + { + id: "claude-login", + name: "Log in with Claude", + description: "Run `claude /login` in a terminal", + }, + ], + capabilities: { + prompt_image: false, + prompt_embedded_context: true, + supports_session_listing: true, + supports_resume_session: true, + }, + }, + }, + requestId, + ); + await emitSessionsList(requestId); + return; + + case "create_session": + setSessionListingDir(command.cwd); + await createSession({ + cwd: command.cwd, + resume: command.resume, + launchSettings: command.launch_settings, + connectEvent: "connected", + requestId, + }); + return; + + case "resume_session": { + try { + const sdkSessions = await listSessions(currentSessionListOptions()); + const matched = sdkSessions.find((entry) => entry.sessionId === command.session_id); + if (!matched) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + setSessionListingDir(matched.cwd ?? process.cwd()); + const historyMessages = await getSessionMessages( + command.session_id, + matched.cwd ? { dir: matched.cwd } : undefined, + ); + const resumeUpdates = mapSessionMessagesToUpdates(historyMessages); + const staleSessions = Array.from(sessions.values()); + const hadActiveSession = staleSessions.length > 0; + await createSession({ + cwd: matched.cwd ?? process.cwd(), + resume: command.session_id, + launchSettings: command.launch_settings, + ...(resumeUpdates.length > 0 ? { resumeUpdates } : {}), + connectEvent: hadActiveSession ? "session_replaced" : "connected", + requestId, + ...(hadActiveSession ? { sessionsToCloseAfterConnect: staleSessions } : {}), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + slashError(command.session_id, `failed to resume session: ${message}`, requestId); + } + return; + } + + case "new_session": + await closeAllSessions(); + setSessionListingDir(command.cwd); + await createSession({ + cwd: command.cwd, + launchSettings: command.launch_settings, + connectEvent: "session_replaced", + requestId, + }); + return; + + case "prompt": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + const text = textFromPrompt(command); + if (!text.trim()) { + return; + } + session.input.enqueue({ + type: "user", + session_id: session.sessionId, + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "text", text }], + }, + } as import("@anthropic-ai/claude-agent-sdk").SDKUserMessage); + return; + } + + case "cancel_turn": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await session.query.interrupt(); + return; + } + + case "set_model": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await session.query.setModel(command.model); + session.model = command.model; + emitSessionUpdate(session.sessionId, { + type: "config_option_update", + option_id: "model", + value: command.model, + }); + return; + } + + case "set_mode": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + const mode = toPermissionMode(command.mode); + if (!mode) { + slashError(command.session_id, `unsupported mode: ${command.mode}`, requestId); + return; + } + await session.query.setPermissionMode(mode); + session.mode = mode; + emitSessionUpdate(session.sessionId, { + type: "current_mode_update", + current_mode_id: mode, + }); + return; + } + + case "generate_session_title": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + try { + await generatePersistedSessionTitle(session.query, command.description); + setSessionListingDir(session.cwd); + await emitSessionsList(requestId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + slashError(command.session_id, `failed to generate session title: ${message}`, requestId); + } + return; + } + + case "rename_session": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + try { + await renameSession( + command.session_id, + command.title, + buildSessionMutationOptions(session.cwd), + ); + setSessionListingDir(session.cwd); + await emitSessionsList(requestId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + slashError(command.session_id, `failed to rename session: ${message}`, requestId); + } + return; + } + + case "get_status_snapshot": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + const account = await session.query.accountInfo(); + writeEvent( + { + event: "status_snapshot", + session_id: session.sessionId, + account: { + email: account.email, + organization: account.organization, + subscription_type: account.subscriptionType, + token_source: account.tokenSource, + api_key_source: account.apiKeySource, + }, + }, + requestId, + ); + return; + } + + case "mcp_status": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await handleMcpStatusCommand(session, requestId); + return; + } + + case "mcp_reconnect": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await handleMcpReconnectCommand(session, command, requestId); + return; + } + + case "mcp_toggle": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await handleMcpToggleCommand(session, command, requestId); + return; + } + + case "mcp_set_servers": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await handleMcpSetServersCommand(session, command, requestId); + return; + } + + case "mcp_authenticate": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await handleMcpAuthenticateCommand(session, command, requestId); + return; + } + + case "mcp_clear_auth": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await handleMcpClearAuthCommand(session, command, requestId); + return; + } + + case "mcp_oauth_callback_url": { + const session = sessionById(command.session_id); + if (!session) { + slashError(command.session_id, `unknown session: ${command.session_id}`, requestId); + return; + } + await handleMcpOauthCallbackUrlCommand(session, command, requestId); + return; + } + + case "permission_response": + handlePermissionResponse(command); + return; + + case "question_response": + handleQuestionResponse(command); + return; + + case "elicitation_response": + handleElicitationResponse(command); + return; + + case "shutdown": + await closeAllSessions(); + process.exit(0); + + default: + failConnection(`unhandled command: ${(command as { command?: string }).command ?? "unknown"}`, requestId); + } +} + +function main(): void { + const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Number.POSITIVE_INFINITY, + }); + + rl.on("line", (line) => { + if (line.trim().length === 0) { + return; + } + void (async () => { + let parsed: { requestId?: string; command: BridgeCommand }; + try { + parsed = parseCommandEnvelope(line); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failConnection(`invalid command envelope: ${message}`); + return; + } + + try { + await handleCommand(parsed.command, parsed.requestId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failConnection( + `bridge command failed (${parsed.command.command}): ${message}`, + parsed.requestId, + ); + } + })(); + }); + + rl.on("close", () => { + void closeAllSessions().finally(() => process.exit(0)); + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/claude-code-rust/agent-sdk/src/bridge/agents.ts b/claude-code-rust/agent-sdk/src/bridge/agents.ts new file mode 100644 index 0000000..f028f56 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/agents.ts @@ -0,0 +1,85 @@ +import type { AvailableAgent } from "../types.js"; +import { emitSessionUpdate } from "./events.js"; +import type { SessionState } from "./session_lifecycle.js"; + +function availableAgentsSignature(agents: AvailableAgent[]): string { + return JSON.stringify(agents); +} + +function normalizeAvailableAgentName(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + return value.trim(); +} + +export function mapAvailableAgents(value: unknown): AvailableAgent[] { + if (!Array.isArray(value)) { + return []; + } + + const byName = new Map(); + for (const entry of value) { + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + const name = normalizeAvailableAgentName(record.name); + if (!name) { + continue; + } + const description = typeof record.description === "string" ? record.description : ""; + const model = typeof record.model === "string" && record.model.trim().length > 0 ? record.model : undefined; + const existing = byName.get(name); + if (!existing) { + byName.set(name, { name, description, model }); + continue; + } + if (existing.description.trim().length === 0 && description.trim().length > 0) { + existing.description = description; + } + if (!existing.model && model) { + existing.model = model; + } + } + + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function mapAvailableAgentsFromNames(value: unknown): AvailableAgent[] { + if (!Array.isArray(value)) { + return []; + } + const byName = new Map(); + for (const entry of value) { + const name = normalizeAvailableAgentName(entry); + if (!name || byName.has(name)) { + continue; + } + byName.set(name, { name, description: "" }); + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function emitAvailableAgentsIfChanged(session: SessionState, agents: AvailableAgent[]): void { + const signature = availableAgentsSignature(agents); + if (session.lastAvailableAgentsSignature === signature) { + return; + } + session.lastAvailableAgentsSignature = signature; + emitSessionUpdate(session.sessionId, { type: "available_agents_update", agents }); +} + +export function refreshAvailableAgents(session: SessionState): void { + if (typeof session.query.supportedAgents !== "function") { + return; + } + void session.query + .supportedAgents() + .then((agents) => { + emitAvailableAgentsIfChanged(session, mapAvailableAgents(agents)); + }) + .catch(() => { + // Best-effort only. + }); +} diff --git a/claude-code-rust/agent-sdk/src/bridge/auth.ts b/claude-code-rust/agent-sdk/src/bridge/auth.ts new file mode 100644 index 0000000..0413ac7 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/auth.ts @@ -0,0 +1,10 @@ +export function looksLikeAuthRequired(input: string): boolean { + const normalized = input.toLowerCase(); + return ( + normalized.includes("/login") || + normalized.includes("auth required") || + normalized.includes("authentication failed") || + normalized.includes("please log in") + ); +} + diff --git a/claude-code-rust/agent-sdk/src/bridge/cache_policy.ts b/claude-code-rust/agent-sdk/src/bridge/cache_policy.ts new file mode 100644 index 0000000..4f7e039 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/cache_policy.ts @@ -0,0 +1,16 @@ +export interface CacheSplitPolicy { + softLimitBytes: number; + hardLimitBytes: number; + previewLimitBytes: number; +} + +export const CACHE_SPLIT_POLICY: Readonly = Object.freeze({ + softLimitBytes: 1536, + hardLimitBytes: 4096, + previewLimitBytes: 2048, +}); + +export function previewKilobyteLabel(policy: Readonly = CACHE_SPLIT_POLICY): string { + const kb = policy.previewLimitBytes / 1024; + return Number.isInteger(kb) ? `${kb}KB` : `${kb.toFixed(1)}KB`; +} diff --git a/claude-code-rust/agent-sdk/src/bridge/commands.ts b/claude-code-rust/agent-sdk/src/bridge/commands.ts new file mode 100644 index 0000000..0123d4f --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/commands.ts @@ -0,0 +1,455 @@ +import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk"; +import type { + BridgeCommand, + BridgeCommandEnvelope, + ElicitationAction, + Json, + McpServerConfig, + ModeInfo, + ModeState, + PermissionOutcome, + QuestionOutcome, + SessionLaunchSettings, +} from "../types.js"; + +const MODE_NAMES: Record = { + default: "Default", + acceptEdits: "Accept Edits", + bypassPermissions: "Bypass Permissions", + plan: "Plan", + dontAsk: "Don't Ask", +}; + +const MODE_OPTIONS: ModeInfo[] = [ + { id: "default", name: "Default", description: "Standard permission flow" }, + { id: "acceptEdits", name: "Accept Edits", description: "Auto-approve edit operations" }, + { id: "plan", name: "Plan", description: "No tool execution" }, + { id: "dontAsk", name: "Don't Ask", description: "Reject non-approved tools" }, + { id: "bypassPermissions", name: "Bypass Permissions", description: "Auto-approve all tools" }, +]; + +function asRecord(value: unknown, context: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${context} must be an object`); + } + return value as Record; +} + +function expectString(record: Record, key: string, context: string): string { + const value = record[key]; + if (typeof value !== "string") { + throw new Error(`${context}.${key} must be a string`); + } + return value; +} + +function optionalString( + record: Record, + key: string, + context: string, +): string | undefined { + const value = record[key]; + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`${context}.${key} must be a string when provided`); + } + return value; +} + +function optionalMetadata(record: Record, key: string): Record { + const value = record[key]; + if (value === undefined || value === null) { + return {}; + } + return asRecord(value, `${key} metadata`) as Record; +} + +function optionalLaunchSettings( + record: Record, + key: string, + context: string, +): SessionLaunchSettings { + const value = record[key]; + if (value === undefined || value === null) { + return {}; + } + const parsed = asRecord(value, `${context}.${key}`); + const language = optionalString(parsed, "language", `${context}.${key}`); + const settings = optionalJsonObject(parsed, "settings", `${context}.${key}`); + const agentProgressSummaries = optionalBoolean( + parsed, + "agent_progress_summaries", + `${context}.${key}`, + ); + return { + ...(language ? { language } : {}), + ...(settings ? { settings } : {}), + ...(agentProgressSummaries !== undefined + ? { agent_progress_summaries: agentProgressSummaries } + : {}), + }; +} + +function optionalBoolean( + record: Record, + key: string, + context: string, +): boolean | undefined { + const value = record[key]; + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "boolean") { + throw new Error(`${context}.${key} must be a boolean when provided`); + } + return value; +} + +function optionalJsonObject( + record: Record, + key: string, + context: string, +): { [key: string]: Json } | undefined { + const value = record[key]; + if (value === undefined || value === null) { + return undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${context}.${key} must be an object when provided`); + } + return value as { [key: string]: Json }; +} + +function expectElicitationAction( + record: Record, + key: string, + context: string, +): ElicitationAction { + const value = expectString(record, key, context); + if (value === "accept" || value === "decline" || value === "cancel") { + return value; + } + throw new Error(`${context}.${key} must be one of accept, decline, cancel`); +} + +function parsePromptChunks( + record: Record, + context: string, +): Array<{ kind: string; value: Json }> { + const rawChunks = record.chunks; + if (!Array.isArray(rawChunks)) { + throw new Error(`${context}.chunks must be an array`); + } + return rawChunks.map((chunk, index) => { + const parsed = asRecord(chunk, `${context}.chunks[${index}]`); + const kind = expectString(parsed, "kind", `${context}.chunks[${index}]`); + return { kind, value: (parsed.value ?? null) as Json }; + }); +} + +function expectBoolean( + record: Record, + key: string, + context: string, +): boolean { + const value = record[key]; + if (typeof value !== "boolean") { + throw new Error(`${context}.${key} must be a boolean`); + } + return value; +} + +function parseMcpServerConfig( + value: unknown, + context: string, +): McpServerConfig { + const record = asRecord(value, context); + const type = expectString(record, "type", context); + switch (type) { + case "stdio": + return { + type, + command: expectString(record, "command", context), + ...(record.args === undefined ? {} : { args: expectStringArray(record, "args", context) }), + ...(record.env === undefined ? {} : { env: expectStringMap(record, "env", context) }), + }; + case "sse": + case "http": + return { + type, + url: expectString(record, "url", context), + ...(record.headers === undefined + ? {} + : { headers: expectStringMap(record, "headers", context) }), + }; + default: + throw new Error(`${context}.type must be one of stdio, sse, http`); + } +} + +function parseMcpServersRecord( + value: unknown, + context: string, +): Record { + const record = asRecord(value, context); + return Object.fromEntries( + Object.entries(record).map(([key, entry]) => [key, parseMcpServerConfig(entry, `${context}.${key}`)]), + ); +} + +export function parseCommandEnvelope(line: string): { requestId?: string; command: BridgeCommand } { + const raw = asRecord(JSON.parse(line) as BridgeCommandEnvelope, "command envelope"); + const requestId = typeof raw.request_id === "string" ? raw.request_id : undefined; + const commandName = expectString(raw, "command", "command envelope"); + + const command: BridgeCommand = (() => { + switch (commandName) { + case "initialize": + return { + command: "initialize", + cwd: expectString(raw, "cwd", "initialize"), + metadata: optionalMetadata(raw, "metadata"), + }; + case "create_session": + return { + command: "create_session", + cwd: expectString(raw, "cwd", "create_session"), + resume: optionalString(raw, "resume", "create_session"), + launch_settings: optionalLaunchSettings(raw, "launch_settings", "create_session"), + metadata: optionalMetadata(raw, "metadata"), + }; + case "resume_session": + return { + command: "resume_session", + session_id: expectString(raw, "session_id", "resume_session"), + launch_settings: optionalLaunchSettings(raw, "launch_settings", "resume_session"), + metadata: optionalMetadata(raw, "metadata"), + }; + case "new_session": + return { + command: "new_session", + cwd: expectString(raw, "cwd", "new_session"), + launch_settings: optionalLaunchSettings(raw, "launch_settings", "new_session"), + }; + case "prompt": + return { + command: "prompt", + session_id: expectString(raw, "session_id", "prompt"), + chunks: parsePromptChunks(raw, "prompt"), + }; + case "cancel_turn": + return { + command: "cancel_turn", + session_id: expectString(raw, "session_id", "cancel_turn"), + }; + case "set_model": + return { + command: "set_model", + session_id: expectString(raw, "session_id", "set_model"), + model: expectString(raw, "model", "set_model"), + }; + case "set_mode": + return { + command: "set_mode", + session_id: expectString(raw, "session_id", "set_mode"), + mode: expectString(raw, "mode", "set_mode"), + }; + case "generate_session_title": + return { + command: "generate_session_title", + session_id: expectString(raw, "session_id", "generate_session_title"), + description: expectString(raw, "description", "generate_session_title"), + }; + case "rename_session": + return { + command: "rename_session", + session_id: expectString(raw, "session_id", "rename_session"), + title: expectString(raw, "title", "rename_session"), + }; + case "get_status_snapshot": + return { + command: "get_status_snapshot", + session_id: expectString(raw, "session_id", "get_status_snapshot"), + }; + case "mcp_status": + case "get_mcp_snapshot": + return { + command: "mcp_status", + session_id: expectString(raw, "session_id", commandName), + }; + case "mcp_reconnect": + return { + command: "mcp_reconnect", + session_id: expectString(raw, "session_id", "mcp_reconnect"), + server_name: expectString(raw, "server_name", "mcp_reconnect"), + }; + case "mcp_toggle": + return { + command: "mcp_toggle", + session_id: expectString(raw, "session_id", "mcp_toggle"), + server_name: expectString(raw, "server_name", "mcp_toggle"), + enabled: expectBoolean(raw, "enabled", "mcp_toggle"), + }; + case "mcp_set_servers": + return { + command: "mcp_set_servers", + session_id: expectString(raw, "session_id", "mcp_set_servers"), + servers: parseMcpServersRecord(raw.servers ?? {}, "mcp_set_servers.servers"), + }; + case "permission_response": { + const outcome = asRecord(raw.outcome, "permission_response.outcome"); + const outcomeType = expectString(outcome, "outcome", "permission_response.outcome"); + if (outcomeType !== "selected" && outcomeType !== "cancelled") { + throw new Error("permission_response.outcome.outcome must be 'selected' or 'cancelled'"); + } + const parsedOutcome: PermissionOutcome = + outcomeType === "selected" + ? { + outcome: "selected", + option_id: expectString(outcome, "option_id", "permission_response.outcome"), + } + : { outcome: "cancelled" }; + return { + command: "permission_response", + session_id: expectString(raw, "session_id", "permission_response"), + tool_call_id: expectString(raw, "tool_call_id", "permission_response"), + outcome: parsedOutcome, + }; + } + case "question_response": { + const outcome = asRecord(raw.outcome, "question_response.outcome"); + const outcomeType = expectString(outcome, "outcome", "question_response.outcome"); + if (outcomeType !== "answered" && outcomeType !== "cancelled") { + throw new Error("question_response.outcome.outcome must be 'answered' or 'cancelled'"); + } + const parsedOutcome: QuestionOutcome = + outcomeType === "answered" + ? { + outcome: "answered", + selected_option_ids: expectStringArray( + outcome, + "selected_option_ids", + "question_response.outcome", + ), + ...(outcome.annotation === undefined || outcome.annotation === null + ? {} + : { annotation: parseQuestionAnnotation(outcome.annotation) }), + } + : { outcome: "cancelled" }; + return { + command: "question_response", + session_id: expectString(raw, "session_id", "question_response"), + tool_call_id: expectString(raw, "tool_call_id", "question_response"), + outcome: parsedOutcome, + }; + } + case "elicitation_response": + return { + command: "elicitation_response", + session_id: expectString(raw, "session_id", "elicitation_response"), + elicitation_request_id: expectString( + raw, + "elicitation_request_id", + "elicitation_response", + ), + action: expectElicitationAction(raw, "action", "elicitation_response"), + ...(optionalJsonObject(raw, "content", "elicitation_response") + ? { content: optionalJsonObject(raw, "content", "elicitation_response") } + : {}), + }; + case "mcp_authenticate": + return { + command: "mcp_authenticate", + session_id: expectString(raw, "session_id", "mcp_authenticate"), + server_name: expectString(raw, "server_name", "mcp_authenticate"), + }; + case "mcp_clear_auth": + return { + command: "mcp_clear_auth", + session_id: expectString(raw, "session_id", "mcp_clear_auth"), + server_name: expectString(raw, "server_name", "mcp_clear_auth"), + }; + case "mcp_oauth_callback_url": + return { + command: "mcp_oauth_callback_url", + session_id: expectString(raw, "session_id", "mcp_oauth_callback_url"), + server_name: expectString(raw, "server_name", "mcp_oauth_callback_url"), + callback_url: expectString(raw, "callback_url", "mcp_oauth_callback_url"), + }; + case "shutdown": + return { command: "shutdown" }; + default: + throw new Error(`unsupported command: ${commandName}`); + } + })(); + + return { requestId, command }; +} + +function expectStringArray( + record: Record, + key: string, + context: string, +): string[] { + const value = record[key]; + if (!Array.isArray(value)) { + throw new Error(`${context}.${key} must be an array`); + } + return value.map((entry, index) => { + if (typeof entry !== "string") { + throw new Error(`${context}.${key}[${index}] must be a string`); + } + return entry; + }); +} + +function expectStringMap( + record: Record, + key: string, + context: string, +): Record { + const value = record[key]; + const parsed = asRecord(value, `${context}.${key}`); + return Object.fromEntries( + Object.entries(parsed).map(([entryKey, entryValue]) => { + if (typeof entryValue !== "string") { + throw new Error(`${context}.${key}.${entryKey} must be a string`); + } + return [entryKey, entryValue]; + }), + ); +} + +function parseQuestionAnnotation(value: unknown): { preview?: string; notes?: string } { + const record = asRecord(value, "question_response.outcome.annotation"); + const preview = optionalString(record, "preview", "question_response.outcome.annotation"); + const notes = optionalString(record, "notes", "question_response.outcome.annotation"); + return { + ...(preview !== undefined ? { preview } : {}), + ...(notes !== undefined ? { notes } : {}), + }; +} + +export function toPermissionMode(mode: string): PermissionMode | null { + if ( + mode === "default" || + mode === "acceptEdits" || + mode === "bypassPermissions" || + mode === "plan" || + mode === "dontAsk" + ) { + return mode; + } + return null; +} + +export function buildModeState(mode: PermissionMode): ModeState { + return { + current_mode_id: mode, + current_mode_name: MODE_NAMES[mode], + available_modes: MODE_OPTIONS, + }; +} + diff --git a/claude-code-rust/agent-sdk/src/bridge/error_classification.ts b/claude-code-rust/agent-sdk/src/bridge/error_classification.ts new file mode 100644 index 0000000..880bce9 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/error_classification.ts @@ -0,0 +1,76 @@ +import type { TurnErrorKind } from "../types.js"; +import { looksLikeAuthRequired } from "./auth.js"; +import { writeEvent } from "./events.js"; +import { emitSessionUpdate } from "./events.js"; +import type { SessionState } from "./session_lifecycle.js"; +import { parseFastModeState } from "./state_parsing.js"; + +export function emitAuthRequired(session: SessionState, detail?: string): void { + if (session.authHintSent) { + return; + } + session.authHintSent = true; + writeEvent({ + event: "auth_required", + method_name: "Claude Login", + method_description: + detail && detail.trim().length > 0 + ? detail + : "Type /login to authenticate.", + }); +} + +export function looksLikePlanLimitError(input: string): boolean { + const normalized = input.toLowerCase(); + return ( + normalized.includes("rate limit") || + normalized.includes("rate-limit") || + normalized.includes("max turns") || + normalized.includes("max budget") || + normalized.includes("quota") || + normalized.includes("plan limit") || + normalized.includes("too many requests") || + normalized.includes("insufficient quota") || + normalized.includes("429") + ); +} + +export function classifyTurnErrorKind( + subtype: string, + errors: string[], + assistantError?: string, +): TurnErrorKind { + const combined = errors.join("\n"); + + if ( + subtype === "error_max_turns" || + subtype === "error_max_budget_usd" || + assistantError === "billing_error" || + assistantError === "rate_limit" || + (combined.length > 0 && looksLikePlanLimitError(combined)) + ) { + return "plan_limit"; + } + + if ( + assistantError === "authentication_failed" || + errors.some((entry) => looksLikeAuthRequired(entry)) + ) { + return "auth_required"; + } + + if (assistantError === "server_error") { + return "internal"; + } + + return "other"; +} + +export function emitFastModeUpdateIfChanged(session: SessionState, value: unknown): void { + const next = parseFastModeState(value); + if (!next || next === session.fastModeState) { + return; + } + session.fastModeState = next; + emitSessionUpdate(session.sessionId, { type: "fast_mode_update", fast_mode_state: next }); +} diff --git a/claude-code-rust/agent-sdk/src/bridge/events.ts b/claude-code-rust/agent-sdk/src/bridge/events.ts new file mode 100644 index 0000000..06243c0 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/events.ts @@ -0,0 +1,123 @@ +import { listSessions, type ListSessionsOptions } from "@anthropic-ai/claude-agent-sdk"; +import type { + BridgeEvent, + BridgeEventEnvelope, + McpOperationError, + SessionUpdate, +} from "../types.js"; +import { buildModeState } from "./commands.js"; +import { mapSdkSessions } from "./history.js"; +import type { SessionState } from "./session_lifecycle.js"; + +const SESSION_LIST_LIMIT = 50; +let sessionListingDir: string | undefined; + +export function buildSessionListOptions( + dir: string | undefined, + limit = SESSION_LIST_LIMIT, +): ListSessionsOptions { + return dir ? { dir, includeWorktrees: true, limit } : { limit }; +} + +export function setSessionListingDir(dir: string | undefined): void { + sessionListingDir = dir; +} + +export function currentSessionListOptions(): ListSessionsOptions { + return buildSessionListOptions(sessionListingDir); +} + +export function writeEvent(event: BridgeEvent, requestId?: string): void { + const envelope: BridgeEventEnvelope = { + ...(requestId ? { request_id: requestId } : {}), + ...event, + }; + process.stdout.write(`${JSON.stringify(envelope)}\n`); +} + +export function failConnection(message: string, requestId?: string): void { + writeEvent({ event: "connection_failed", message }, requestId); +} + +export function slashError(sessionId: string, message: string, requestId?: string): void { + writeEvent({ event: "slash_error", session_id: sessionId, message }, requestId); +} + +export function emitMcpOperationError( + sessionId: string, + error: McpOperationError, + requestId?: string, +): void { + writeEvent({ event: "mcp_operation_error", session_id: sessionId, error }, requestId); +} + +export function emitSessionUpdate(sessionId: string, update: SessionUpdate): void { + writeEvent({ event: "session_update", session_id: sessionId, update }); +} + +export function emitConnectEvent(session: SessionState): void { + const historyUpdates = session.resumeUpdates; + const connectEvent: BridgeEvent = + session.connectEvent === "session_replaced" + ? { + event: "session_replaced", + session_id: session.sessionId, + cwd: session.cwd, + model_name: session.model, + available_models: session.availableModels, + mode: session.mode ? buildModeState(session.mode) : null, + ...(historyUpdates && historyUpdates.length > 0 ? { history_updates: historyUpdates } : {}), + } + : { + event: "connected", + session_id: session.sessionId, + cwd: session.cwd, + model_name: session.model, + available_models: session.availableModels, + mode: session.mode ? buildModeState(session.mode) : null, + ...(historyUpdates && historyUpdates.length > 0 ? { history_updates: historyUpdates } : {}), + }; + writeEvent(connectEvent, session.connectRequestId); + session.connectRequestId = undefined; + session.connected = true; + session.authHintSent = false; + session.resumeUpdates = undefined; + + const staleSessions = session.sessionsToCloseAfterConnect; + session.sessionsToCloseAfterConnect = undefined; + if (!staleSessions || staleSessions.length === 0) { + refreshSessionsList(); + return; + } + void (async () => { + // Lazy import to break circular dependency at module-evaluation time. + const { sessions, closeSession } = await import("./session_lifecycle.js"); + for (const stale of staleSessions) { + if (stale === session) { + continue; + } + if (sessions.get(stale.sessionId) === stale) { + sessions.delete(stale.sessionId); + } + await closeSession(stale); + } + refreshSessionsList(); + })(); +} + +export async function emitSessionsList(requestId?: string): Promise { + try { + const sdkSessions = await listSessions(currentSessionListOptions()); + writeEvent({ event: "sessions_listed", sessions: mapSdkSessions(sdkSessions, SESSION_LIST_LIMIT) }, requestId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[sdk warn] listSessions failed: ${message}`); + writeEvent({ event: "sessions_listed", sessions: [] }, requestId); + } +} + +export function refreshSessionsList(): void { + void emitSessionsList().catch(() => { + // Defensive no-op. + }); +} diff --git a/claude-code-rust/agent-sdk/src/bridge/history.ts b/claude-code-rust/agent-sdk/src/bridge/history.ts new file mode 100644 index 0000000..93caca8 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/history.ts @@ -0,0 +1,164 @@ +import type { SDKSessionInfo, SessionMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { SessionListEntry, SessionUpdate, ToolCall } from "../types.js"; +import { asRecordOrNull } from "./shared.js"; +import { TOOL_RESULT_TYPES, buildToolResultFields, createToolCall, isToolUseBlockType } from "./tooling.js"; + +function nonEmptyTrimmed(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function messageCandidates(raw: unknown): Record[] { + const candidates: Record[] = []; + const topLevel = asRecordOrNull(raw); + if (topLevel) { + candidates.push(topLevel); + const nested = asRecordOrNull(topLevel.message); + if (nested) { + candidates.push(nested); + } + } + return candidates; +} + +function pushResumeTextChunk(updates: SessionUpdate[], role: "user" | "assistant", text: string): void { + if (!text.trim()) { + return; + } + if (role === "assistant") { + updates.push({ type: "agent_message_chunk", content: { type: "text", text } }); + return; + } + updates.push({ type: "user_message_chunk", content: { type: "text", text } }); +} + +function pushResumeToolUse( + updates: SessionUpdate[], + toolCalls: Map, + block: Record, +): void { + const toolUseId = typeof block.id === "string" ? block.id : ""; + if (!toolUseId) { + return; + } + const name = typeof block.name === "string" ? block.name : "Tool"; + const input = asRecordOrNull(block.input) ?? {}; + + const toolCall = createToolCall(toolUseId, name, input); + toolCall.status = "in_progress"; + toolCalls.set(toolUseId, toolCall); + updates.push({ type: "tool_call", tool_call: toolCall }); +} + +function pushResumeToolResult( + updates: SessionUpdate[], + toolCalls: Map, + block: Record, +): void { + const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; + if (!toolUseId) { + return; + } + const isError = Boolean(block.is_error); + const base = toolCalls.get(toolUseId); + const fields = buildToolResultFields(isError, block.content, base, block); + updates.push({ type: "tool_call_update", tool_call_update: { tool_call_id: toolUseId, fields } }); + + if (!base) { + return; + } + base.status = fields.status ?? base.status; + if (fields.raw_output) { + base.raw_output = fields.raw_output; + } + if (fields.content) { + base.content = fields.content; + } + if (fields.output_metadata) { + base.output_metadata = fields.output_metadata; + } +} + +function summaryFromSession(info: SDKSessionInfo): string { + return ( + nonEmptyTrimmed(info.summary) ?? + nonEmptyTrimmed(info.customTitle) ?? + nonEmptyTrimmed(info.firstPrompt) ?? + info.sessionId + ); +} + +export function mapSdkSessionInfo(info: SDKSessionInfo): SessionListEntry { + return { + session_id: info.sessionId, + summary: summaryFromSession(info), + last_modified_ms: info.lastModified, + file_size_bytes: info.fileSize, + ...(nonEmptyTrimmed(info.cwd) ? { cwd: info.cwd?.trim() } : {}), + ...(nonEmptyTrimmed(info.gitBranch) ? { git_branch: info.gitBranch?.trim() } : {}), + ...(nonEmptyTrimmed(info.customTitle) ? { custom_title: info.customTitle?.trim() } : {}), + ...(nonEmptyTrimmed(info.firstPrompt) ? { first_prompt: info.firstPrompt?.trim() } : {}), + }; +} + +export function mapSdkSessions(infos: SDKSessionInfo[], limit = 50): SessionListEntry[] { + const sorted = [...infos].sort((a, b) => b.lastModified - a.lastModified); + const entries: SessionListEntry[] = []; + const seen = new Set(); + for (const info of sorted) { + if (!info.sessionId || seen.has(info.sessionId)) { + continue; + } + seen.add(info.sessionId); + entries.push(mapSdkSessionInfo(info)); + if (entries.length >= limit) { + break; + } + } + return entries; +} + +export function mapSessionMessagesToUpdates(messages: SessionMessage[]): SessionUpdate[] { + const updates: SessionUpdate[] = []; + const toolCalls = new Map(); + + for (const entry of messages) { + const fallbackRole = entry.type === "assistant" ? "assistant" : "user"; + for (const message of messageCandidates(entry.message)) { + const roleCandidate = message.role; + const role = roleCandidate === "assistant" || roleCandidate === "user" ? roleCandidate : fallbackRole; + + const content = Array.isArray(message.content) ? message.content : []; + for (const item of content) { + const block = asRecordOrNull(item); + if (!block) { + continue; + } + const blockType = typeof block.type === "string" ? block.type : ""; + if (blockType === "thinking") { + continue; + } + if (blockType === "text" && typeof block.text === "string") { + pushResumeTextChunk(updates, role, block.text); + continue; + } + if (isToolUseBlockType(blockType) && role === "assistant") { + pushResumeToolUse(updates, toolCalls, block); + continue; + } + if (TOOL_RESULT_TYPES.has(blockType)) { + pushResumeToolResult(updates, toolCalls, block); + continue; + } + if (blockType === "image") { + pushResumeTextChunk(updates, role, "[image]"); + } + } + } + } + + return updates; +} diff --git a/claude-code-rust/agent-sdk/src/bridge/mcp.ts b/claude-code-rust/agent-sdk/src/bridge/mcp.ts new file mode 100644 index 0000000..cb9a4b9 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/mcp.ts @@ -0,0 +1,427 @@ +import type { BridgeCommand, McpServerConfig, McpServerStatus } from "../types.js"; +import { emitMcpOperationError, slashError, writeEvent } from "./events.js"; +import type { SessionState } from "./session_lifecycle.js"; + +type QueryWithMcpAuth = import("@anthropic-ai/claude-agent-sdk").Query & { + mcpAuthenticate?: (serverName: string) => Promise; + mcpClearAuth?: (serverName: string) => Promise; + mcpSubmitOAuthCallbackUrl?: (serverName: string, callbackUrl: string) => Promise; +}; + +type McpAuthMethodName = + | "mcpAuthenticate" + | "mcpClearAuth" + | "mcpSubmitOAuthCallbackUrl"; + +export const MCP_STALE_STATUS_REVALIDATION_COOLDOWN_MS = 30_000; +const knownConnectedMcpServers = new Set(); + +function queryWithMcpAuth(session: SessionState): QueryWithMcpAuth { + return session.query as QueryWithMcpAuth; +} + +async function callMcpAuthMethod( + session: SessionState, + methodName: McpAuthMethodName, + args: string[], +): Promise { + const query = queryWithMcpAuth(session); + switch (methodName) { + case "mcpAuthenticate": + if (typeof query.mcpAuthenticate !== "function") { + throw new Error("installed SDK does not support mcpAuthenticate"); + } + return await query.mcpAuthenticate(args[0] ?? ""); + case "mcpClearAuth": + if (typeof query.mcpClearAuth !== "function") { + throw new Error("installed SDK does not support mcpClearAuth"); + } + return await query.mcpClearAuth(args[0] ?? ""); + case "mcpSubmitOAuthCallbackUrl": + if (typeof query.mcpSubmitOAuthCallbackUrl !== "function") { + throw new Error("installed SDK does not support mcpSubmitOAuthCallbackUrl"); + } + return await query.mcpSubmitOAuthCallbackUrl(args[0] ?? "", args[1] ?? ""); + } +} + +function extractMcpAuthRedirect( + serverName: string, + value: unknown, +): import("../types.js").McpAuthRedirect | null { + if (!value || typeof value !== "object") { + return null; + } + const authUrl = Reflect.get(value, "authUrl"); + if (typeof authUrl !== "string" || authUrl.trim().length === 0) { + return null; + } + const requiresUserAction = Reflect.get(value, "requiresUserAction"); + return { + server_name: serverName, + auth_url: authUrl, + requires_user_action: requiresUserAction === true, + }; +} + +function emitMcpCommandError( + sessionId: string, + operation: string, + message: string, + requestId?: string, + serverName?: string, +): void { + emitMcpOperationError( + sessionId, + { + ...(serverName ? { server_name: serverName } : {}), + operation, + message, + }, + requestId, + ); +} + +export async function emitMcpSnapshotEvent( + session: SessionState, + requestId?: string, +): Promise { + const servers = await session.query.mcpServerStatus(); + let mapped = servers.map(mapMcpServerStatus); + mapped = await reconcileSuspiciousMcpStatuses(session, mapped); + rememberKnownConnectedMcpServers(mapped); + writeEvent( + { + event: "mcp_snapshot", + session_id: session.sessionId, + servers: mapped, + }, + requestId, + ); + return mapped; +} + +export function staleMcpAuthCandidates( + servers: readonly McpServerStatus[], + knownConnectedServerNames: ReadonlySet, + lastRevalidatedAt: ReadonlyMap, + now = Date.now(), + cooldownMs = MCP_STALE_STATUS_REVALIDATION_COOLDOWN_MS, +): string[] { + return servers + .filter((server) => { + if (server.status !== "needs-auth") { + return false; + } + if (!knownConnectedServerNames.has(server.name)) { + return false; + } + const lastAttempt = lastRevalidatedAt.get(server.name) ?? 0; + return now - lastAttempt >= cooldownMs; + }) + .map((server) => server.name); +} + +function rememberKnownConnectedMcpServers(servers: readonly McpServerStatus[]): void { + for (const server of servers) { + if (server.status === "connected") { + knownConnectedMcpServers.add(server.name); + } + } +} + +function forgetKnownConnectedMcpServer(serverName: string): void { + knownConnectedMcpServers.delete(serverName); +} + +async function reconcileSuspiciousMcpStatuses( + session: SessionState, + servers: McpServerStatus[], +): Promise { + const candidates = staleMcpAuthCandidates( + servers, + knownConnectedMcpServers, + session.mcpStatusRevalidatedAt, + ); + if (candidates.length === 0) { + return servers; + } + + const now = Date.now(); + for (const serverName of candidates) { + session.mcpStatusRevalidatedAt.set(serverName, now); + console.error( + `[sdk mcp reconcile] session=${session.sessionId} server=${serverName} ` + + `status=needs-auth reason=previously-connected action=reconnect`, + ); + try { + await session.query.reconnectMcpServer(serverName); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `[sdk mcp reconcile] session=${session.sessionId} server=${serverName} ` + + `action=reconnect failed=${message}`, + ); + } + } + + return (await session.query.mcpServerStatus()).map(mapMcpServerStatus); +} + +function shouldKeepMonitoringMcpAuth(server: McpServerStatus | undefined): boolean { + return server?.status === "needs-auth" || server?.status === "pending"; +} + +function scheduleMcpAuthSnapshotMonitor( + session: SessionState, + serverName: string, + attempt = 0, +): void { + const maxAttempts = 180; + const delayMs = 1000; + setTimeout(() => { + void monitorMcpAuthSnapshot(session, serverName, attempt + 1, maxAttempts, delayMs); + }, delayMs); +} + +async function monitorMcpAuthSnapshot( + session: SessionState, + serverName: string, + attempt: number, + maxAttempts: number, + delayMs: number, +): Promise { + try { + const servers = await emitMcpSnapshotEvent(session); + const server = servers.find((candidate) => candidate.name === serverName); + if (attempt < maxAttempts && shouldKeepMonitoringMcpAuth(server)) { + setTimeout(() => { + void monitorMcpAuthSnapshot(session, serverName, attempt + 1, maxAttempts, delayMs); + }, delayMs); + } + } catch { + if (attempt < maxAttempts) { + setTimeout(() => { + void monitorMcpAuthSnapshot(session, serverName, attempt + 1, maxAttempts, delayMs); + }, delayMs); + } + } +} + +export async function handleMcpStatusCommand( + session: SessionState, + requestId?: string, +): Promise { + try { + await emitMcpSnapshotEvent(session, requestId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeEvent( + { + event: "mcp_snapshot", + session_id: session.sessionId, + servers: [], + error: message, + }, + requestId, + ); + } +} + +export async function handleMcpReconnectCommand( + session: SessionState, + command: Extract, + requestId?: string, +): Promise { + try { + await session.query.reconnectMcpServer(command.server_name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emitMcpCommandError( + command.session_id, + "reconnect", + message, + requestId, + command.server_name, + ); + } +} + +export async function handleMcpToggleCommand( + session: SessionState, + command: Extract, + requestId?: string, +): Promise { + try { + await session.query.toggleMcpServer(command.server_name, command.enabled); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emitMcpCommandError(command.session_id, "toggle", message, requestId, command.server_name); + } +} + +export async function handleMcpSetServersCommand( + session: SessionState, + command: Extract, + requestId?: string, +): Promise { + try { + await session.query.setMcpServers(command.servers as Record); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + slashError(command.session_id, `failed to set MCP servers: ${message}`, requestId); + } +} + +export async function handleMcpAuthenticateCommand( + session: SessionState, + command: Extract, + requestId?: string, +): Promise { + try { + const result = await callMcpAuthMethod(session, "mcpAuthenticate", [command.server_name]); + const redirect = extractMcpAuthRedirect(command.server_name, result); + if (redirect) { + writeEvent({ + event: "mcp_auth_redirect", + session_id: command.session_id, + redirect, + }); + } + scheduleMcpAuthSnapshotMonitor(session, command.server_name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emitMcpCommandError( + command.session_id, + "authenticate", + message, + requestId, + command.server_name, + ); + } +} + +export async function handleMcpClearAuthCommand( + session: SessionState, + command: Extract, + requestId?: string, +): Promise { + try { + await callMcpAuthMethod(session, "mcpClearAuth", [command.server_name]); + forgetKnownConnectedMcpServer(command.server_name); + session.mcpStatusRevalidatedAt.delete(command.server_name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emitMcpCommandError( + command.session_id, + "clear-auth", + message, + requestId, + command.server_name, + ); + } +} + +export async function handleMcpOauthCallbackUrlCommand( + session: SessionState, + command: Extract, + requestId?: string, +): Promise { + try { + await callMcpAuthMethod(session, "mcpSubmitOAuthCallbackUrl", [ + command.server_name, + command.callback_url, + ]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emitMcpCommandError( + command.session_id, + "submit-callback-url", + message, + requestId, + command.server_name, + ); + } +} + +function mapMcpServerStatus( + status: Awaited>[number], +): McpServerStatus { + return { + name: status.name, + status: status.status, + ...(status.serverInfo + ? { + server_info: { + name: status.serverInfo.name, + version: status.serverInfo.version, + }, + } + : {}), + ...(status.error ? { error: status.error } : {}), + ...(status.config ? { config: mapMcpServerStatusConfig(status.config) } : {}), + ...(status.scope ? { scope: status.scope } : {}), + tools: Array.isArray(status.tools) + ? status.tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(tool.annotations + ? { + annotations: { + ...(typeof tool.annotations.readOnly === "boolean" + ? { read_only: tool.annotations.readOnly } + : {}), + ...(typeof tool.annotations.destructive === "boolean" + ? { destructive: tool.annotations.destructive } + : {}), + ...(typeof tool.annotations.openWorld === "boolean" + ? { open_world: tool.annotations.openWorld } + : {}), + }, + } + : {}), + })) + : [], + }; +} + +function mapMcpServerStatusConfig( + config: NonNullable< + Awaited>[number]["config"] + >, +): import("../types.js").McpServerStatusConfig { + switch (config.type) { + case "stdio": + return { + type: "stdio", + command: config.command, + ...(Array.isArray(config.args) && config.args.length > 0 ? { args: config.args } : {}), + ...(config.env ? { env: config.env } : {}), + }; + case "sse": + return { + type: "sse", + url: config.url, + ...(config.headers ? { headers: config.headers } : {}), + }; + case "http": + return { + type: "http", + url: config.url, + ...(config.headers ? { headers: config.headers } : {}), + }; + case "sdk": + return { + type: "sdk", + name: config.name, + }; + case "claudeai-proxy": + return { + type: "claudeai-proxy", + url: config.url, + id: config.id, + }; + default: + throw new Error(`unsupported MCP status config: ${JSON.stringify(config)}`); + } +} diff --git a/claude-code-rust/agent-sdk/src/bridge/message_handlers.ts b/claude-code-rust/agent-sdk/src/bridge/message_handlers.ts new file mode 100644 index 0000000..55b6e42 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/message_handlers.ts @@ -0,0 +1,501 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { AvailableCommand, BridgeCommand, ToolCallUpdateFields } from "../types.js"; +import { asRecordOrNull } from "./shared.js"; +import { toPermissionMode, buildModeState } from "./commands.js"; +import { writeEvent, emitSessionUpdate, emitConnectEvent, refreshSessionsList } from "./events.js"; +import { TOOL_RESULT_TYPES, unwrapToolUseResult } from "./tooling.js"; +import { + emitToolCall, + emitPlanIfTodoWrite, + emitToolResultUpdate, + finalizeOpenToolCalls, + emitToolProgressUpdate, + emitToolSummaryUpdate, + ensureToolCallVisible, + resolveTaskToolUseId, + taskProgressText, +} from "./tool_calls.js"; +import { emitAuthRequired, classifyTurnErrorKind, emitFastModeUpdateIfChanged } from "./error_classification.js"; +import { mapAvailableAgents, mapAvailableAgentsFromNames, emitAvailableAgentsIfChanged, refreshAvailableAgents } from "./agents.js"; +import { buildRateLimitUpdate, numberField } from "./state_parsing.js"; +import { looksLikeAuthRequired } from "./auth.js"; +import type { SessionState } from "./session_lifecycle.js"; +import { updateSessionId } from "./session_lifecycle.js"; + +export function textFromPrompt(command: Extract): string { + const chunks = command.chunks ?? []; + return chunks + .map((chunk) => { + if (chunk.kind !== "text") { + return ""; + } + return typeof chunk.value === "string" ? chunk.value : ""; + }) + .filter((part) => part.length > 0) + .join(""); +} + +export function handleTaskSystemMessage( + session: SessionState, + subtype: string, + msg: Record, +): void { + if (subtype !== "task_started" && subtype !== "task_progress" && subtype !== "task_notification") { + return; + } + + const taskId = typeof msg.task_id === "string" ? msg.task_id : ""; + const explicitToolUseId = typeof msg.tool_use_id === "string" ? msg.tool_use_id : ""; + if (taskId && explicitToolUseId) { + session.taskToolUseIds.set(taskId, explicitToolUseId); + } + const toolUseId = resolveTaskToolUseId(session, msg); + if (!toolUseId) { + return; + } + + const toolCall = ensureToolCallVisible(session, toolUseId, "Agent", {}); + if (toolCall.status === "pending") { + toolCall.status = "in_progress"; + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields: { status: "in_progress" } }, + }); + } + + if (subtype === "task_started") { + const description = typeof msg.description === "string" ? msg.description : ""; + if (!description) { + return; + } + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { + tool_call_id: toolUseId, + fields: { + status: "in_progress", + raw_output: description, + content: [{ type: "content", content: { type: "text", text: description } }], + }, + }, + }); + return; + } + + if (subtype === "task_progress") { + const progress = taskProgressText(msg); + if (!progress) { + return; + } + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { + tool_call_id: toolUseId, + fields: { + status: "in_progress", + raw_output: progress, + content: [{ type: "content", content: { type: "text", text: progress } }], + }, + }, + }); + return; + } + + const status = typeof msg.status === "string" ? msg.status : ""; + const summary = typeof msg.summary === "string" ? msg.summary : ""; + const finalStatus = status === "completed" ? "completed" : "failed"; + const fields: ToolCallUpdateFields = { status: finalStatus }; + if (summary) { + fields.raw_output = summary; + fields.content = [{ type: "content", content: { type: "text", text: summary } }]; + } + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields }, + }); + toolCall.status = finalStatus; + if (taskId) { + session.taskToolUseIds.delete(taskId); + } +} + +export function handleContentBlock(session: SessionState, block: Record): void { + const blockType = typeof block.type === "string" ? block.type : ""; + + if (blockType === "text") { + const text = typeof block.text === "string" ? block.text : ""; + if (text) { + emitSessionUpdate(session.sessionId, { type: "agent_message_chunk", content: { type: "text", text } }); + } + return; + } + + if (blockType === "thinking") { + const text = typeof block.thinking === "string" ? block.thinking : ""; + if (text) { + emitSessionUpdate(session.sessionId, { type: "agent_thought_chunk", content: { type: "text", text } }); + } + return; + } + + if (blockType === "tool_use" || blockType === "server_tool_use" || blockType === "mcp_tool_use") { + const toolUseId = typeof block.id === "string" ? block.id : ""; + const name = typeof block.name === "string" ? block.name : "Tool"; + const input = + block.input && typeof block.input === "object" ? (block.input as Record) : {}; + if (!toolUseId) { + return; + } + emitPlanIfTodoWrite(session, name, input); + emitToolCall(session, toolUseId, name, input); + return; + } + + if (TOOL_RESULT_TYPES.has(blockType)) { + const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; + if (!toolUseId) { + return; + } + const isError = Boolean(block.is_error); + emitToolResultUpdate(session, toolUseId, isError, block.content, block); + } +} + +export function handleStreamEvent(session: SessionState, event: Record): void { + const eventType = typeof event.type === "string" ? event.type : ""; + + if (eventType === "content_block_start") { + if (event.content_block && typeof event.content_block === "object") { + handleContentBlock(session, event.content_block as Record); + } + return; + } + + if (eventType === "content_block_delta") { + if (!event.delta || typeof event.delta !== "object") { + return; + } + const delta = event.delta as Record; + const deltaType = typeof delta.type === "string" ? delta.type : ""; + if (deltaType === "text_delta") { + const text = typeof delta.text === "string" ? delta.text : ""; + if (text) { + emitSessionUpdate(session.sessionId, { type: "agent_message_chunk", content: { type: "text", text } }); + } + } else if (deltaType === "thinking_delta") { + const text = typeof delta.thinking === "string" ? delta.thinking : ""; + if (text) { + emitSessionUpdate(session.sessionId, { type: "agent_thought_chunk", content: { type: "text", text } }); + } + } + } +} + +export function handleAssistantMessage(session: SessionState, message: Record): void { + const assistantError = typeof message.error === "string" ? message.error : ""; + if (assistantError.length > 0) { + session.lastAssistantError = assistantError; + } + + const messageObject = + message.message && typeof message.message === "object" + ? (message.message as Record) + : null; + if (!messageObject) { + return; + } + const content = Array.isArray(messageObject.content) ? messageObject.content : []; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const blockRecord = block as Record; + const blockType = typeof blockRecord.type === "string" ? blockRecord.type : ""; + if ( + blockType === "tool_use" || + blockType === "server_tool_use" || + blockType === "mcp_tool_use" || + TOOL_RESULT_TYPES.has(blockType) + ) { + handleContentBlock(session, blockRecord); + } + } +} + +export function handleUserToolResultBlocks(session: SessionState, message: Record): void { + const messageObject = + message.message && typeof message.message === "object" + ? (message.message as Record) + : null; + if (!messageObject) { + return; + } + const content = Array.isArray(messageObject.content) ? messageObject.content : []; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const blockRecord = block as Record; + const blockType = typeof blockRecord.type === "string" ? blockRecord.type : ""; + if (TOOL_RESULT_TYPES.has(blockType)) { + handleContentBlock(session, blockRecord); + } + } +} + +export function handleResultMessage(session: SessionState, message: Record): void { + emitFastModeUpdateIfChanged(session, message.fast_mode_state); + + const subtype = typeof message.subtype === "string" ? message.subtype : ""; + if (subtype === "success") { + session.lastAssistantError = undefined; + finalizeOpenToolCalls(session, "completed"); + writeEvent({ event: "turn_complete", session_id: session.sessionId }); + return; + } + + const errors = + Array.isArray(message.errors) && message.errors.every((entry) => typeof entry === "string") + ? (message.errors as string[]) + : []; + const assistantError = session.lastAssistantError; + const authHint = errors.find((entry) => looksLikeAuthRequired(entry)); + if (authHint) { + emitAuthRequired(session, authHint); + } + if (assistantError === "authentication_failed") { + emitAuthRequired(session); + } + finalizeOpenToolCalls(session, "failed"); + const errorKind = classifyTurnErrorKind(subtype, errors, assistantError); + const fallback = subtype ? `turn failed: ${subtype}` : "turn failed"; + writeEvent({ + event: "turn_error", + session_id: session.sessionId, + message: errors.length > 0 ? errors.join("\n") : fallback, + error_kind: errorKind, + ...(subtype ? { sdk_result_subtype: subtype } : {}), + ...(assistantError ? { assistant_error: assistantError } : {}), + }); + session.lastAssistantError = undefined; +} + +export function handleSdkMessage(session: SessionState, message: SDKMessage): void { + const msg = message as unknown as Record; + const type = typeof msg.type === "string" ? msg.type : ""; + + if (type === "system") { + const subtype = typeof msg.subtype === "string" ? msg.subtype : ""; + if (subtype === "init") { + const previousSessionId = session.sessionId; + const incomingSessionId = typeof msg.session_id === "string" ? msg.session_id : session.sessionId; + updateSessionId(session, incomingSessionId); + const previousModelName = session.model; + const modelName = typeof msg.model === "string" ? msg.model : session.model; + session.model = modelName; + + const incomingMode = typeof msg.permissionMode === "string" ? toPermissionMode(msg.permissionMode) : null; + if (incomingMode) { + session.mode = incomingMode; + } + emitFastModeUpdateIfChanged(session, msg.fast_mode_state); + + if (!session.connected) { + emitConnectEvent(session); + } else if (previousSessionId !== session.sessionId) { + const historyUpdates = session.resumeUpdates; + writeEvent({ + event: "session_replaced", + session_id: session.sessionId, + cwd: session.cwd, + model_name: session.model, + available_models: session.availableModels, + mode: session.mode ? buildModeState(session.mode) : null, + ...(historyUpdates && historyUpdates.length > 0 + ? { history_updates: historyUpdates } + : {}), + }); + session.resumeUpdates = undefined; + refreshSessionsList(); + } else { + if (session.model !== previousModelName) { + emitSessionUpdate(session.sessionId, { + type: "config_option_update", + option_id: "model", + value: session.model, + }); + } + if (incomingMode) { + emitSessionUpdate(session.sessionId, { + type: "mode_state_update", + mode: buildModeState(incomingMode), + }); + } + } + + if (Array.isArray(msg.slash_commands)) { + const commands: AvailableCommand[] = msg.slash_commands + .filter((entry): entry is string => typeof entry === "string") + .map((name) => ({ name, description: "", input_hint: undefined })); + if (commands.length > 0) { + emitSessionUpdate(session.sessionId, { type: "available_commands_update", commands }); + } + } + + if (session.lastAvailableAgentsSignature === undefined && Array.isArray(msg.agents)) { + emitAvailableAgentsIfChanged(session, mapAvailableAgentsFromNames(msg.agents)); + } + + void session.query + .supportedCommands() + .then((commands) => { + const mapped: AvailableCommand[] = commands.map((command) => ({ + name: command.name, + description: command.description ?? "", + input_hint: command.argumentHint ?? undefined, + })); + emitSessionUpdate(session.sessionId, { type: "available_commands_update", commands: mapped }); + }) + .catch(() => { + // Best-effort only; slash commands from init were already emitted. + }); + refreshAvailableAgents(session); + return; + } + + if (subtype === "status") { + const mode = + typeof msg.permissionMode === "string" ? toPermissionMode(msg.permissionMode) : null; + if (mode) { + session.mode = mode; + emitSessionUpdate(session.sessionId, { type: "current_mode_update", current_mode_id: mode }); + } + if (msg.status === "compacting") { + emitSessionUpdate(session.sessionId, { type: "session_status_update", status: "compacting" }); + } else if (msg.status === null) { + emitSessionUpdate(session.sessionId, { type: "session_status_update", status: "idle" }); + } + emitFastModeUpdateIfChanged(session, msg.fast_mode_state); + return; + } + + if (subtype === "compact_boundary") { + const compactMetadata = asRecordOrNull(msg.compact_metadata); + if (!compactMetadata) { + return; + } + const trigger = compactMetadata.trigger; + const preTokens = numberField(compactMetadata, "pre_tokens", "preTokens"); + if ((trigger === "manual" || trigger === "auto") && preTokens !== undefined) { + emitSessionUpdate(session.sessionId, { + type: "compaction_boundary", + trigger, + pre_tokens: preTokens, + }); + } + return; + } + + if (subtype === "local_command_output") { + const content = typeof msg.content === "string" ? msg.content : ""; + if (content.trim().length > 0) { + emitSessionUpdate(session.sessionId, { + type: "agent_message_chunk", + content: { type: "text", text: content }, + }); + } + return; + } + + if (subtype === "elicitation_complete") { + const elicitationId = typeof msg.elicitation_id === "string" ? msg.elicitation_id : ""; + if (!elicitationId) { + return; + } + writeEvent({ + event: "elicitation_complete", + session_id: session.sessionId, + completion: { + elicitation_id: elicitationId, + ...(typeof msg.mcp_server_name === "string" ? { server_name: msg.mcp_server_name } : {}), + }, + }); + return; + } + + handleTaskSystemMessage(session, subtype, msg); + return; + } + + if (type === "auth_status") { + const output = Array.isArray(msg.output) + ? msg.output.filter((entry): entry is string => typeof entry === "string").join("\n") + : ""; + const errorText = typeof msg.error === "string" ? msg.error : ""; + const combined = [errorText, output].filter((entry) => entry.length > 0).join("\n"); + if (combined && looksLikeAuthRequired(combined)) { + emitAuthRequired(session, combined); + } + return; + } + + if (type === "stream_event") { + if (msg.event && typeof msg.event === "object") { + handleStreamEvent(session, msg.event as Record); + } + return; + } + + if (type === "tool_progress") { + const toolUseId = typeof msg.tool_use_id === "string" ? msg.tool_use_id : ""; + const toolName = typeof msg.tool_name === "string" ? msg.tool_name : "Tool"; + if (toolUseId) { + emitToolProgressUpdate(session, toolUseId, toolName); + } + return; + } + + if (type === "tool_use_summary") { + const summary = typeof msg.summary === "string" ? msg.summary : ""; + const toolIds = Array.isArray(msg.preceding_tool_use_ids) + ? msg.preceding_tool_use_ids.filter((id): id is string => typeof id === "string") + : []; + if (summary && toolIds.length > 0) { + for (const toolUseId of toolIds) { + emitToolSummaryUpdate(session, toolUseId, summary); + } + } + return; + } + + if (type === "rate_limit_event") { + const update = buildRateLimitUpdate(msg.rate_limit_info); + if (update) { + emitSessionUpdate(session.sessionId, update); + } + return; + } + + if (type === "user") { + handleUserToolResultBlocks(session, msg); + + const toolUseId = typeof msg.parent_tool_use_id === "string" ? msg.parent_tool_use_id : ""; + if (toolUseId && "tool_use_result" in msg) { + const parsed = unwrapToolUseResult(msg.tool_use_result); + emitToolResultUpdate(session, toolUseId, parsed.isError, parsed.content, msg.tool_use_result); + } + return; + } + + if (type === "assistant") { + if (msg.error === "authentication_failed") { + emitAuthRequired(session); + } + handleAssistantMessage(session, msg); + return; + } + + if (type === "result") { + handleResultMessage(session, msg); + } +} diff --git a/claude-code-rust/agent-sdk/src/bridge/permissions.ts b/claude-code-rust/agent-sdk/src/bridge/permissions.ts new file mode 100644 index 0000000..b363b56 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/permissions.ts @@ -0,0 +1,144 @@ +import type { + PermissionResult, + PermissionRuleValue, + PermissionUpdate, +} from "@anthropic-ai/claude-agent-sdk"; +import type { PermissionOutcome, PermissionOption } from "../types.js"; + +type PermissionSuggestionsByScope = { + session: PermissionUpdate[]; + persistent: PermissionUpdate[]; +}; + +const SESSION_PERMISSION_DESTINATIONS = new Set(["session", "cliArg"]); +const PERSISTENT_PERMISSION_DESTINATIONS = new Set(["userSettings", "projectSettings", "localSettings"]); + +function formatPermissionRule(rule: PermissionRuleValue): string { + return rule.ruleContent === undefined ? rule.toolName : `${rule.toolName}(${rule.ruleContent})`; +} + +export function formatPermissionUpdates(updates: PermissionUpdate[] | undefined): string { + if (!updates || updates.length === 0) { + return ""; + } + return updates + .map((update) => { + if (update.type === "addRules" || update.type === "replaceRules" || update.type === "removeRules") { + const rules = update.rules.map((rule) => formatPermissionRule(rule)).join(", "); + return `${update.type}:${update.behavior}:${update.destination}=[${rules}]`; + } + if (update.type === "setMode") { + return `${update.type}:${update.mode}:${update.destination}`; + } + return `${update.type}:${update.destination}=[${update.directories.join(", ")}]`; + }) + .join(" | "); +} + +function splitPermissionSuggestionsByScope( + suggestions: PermissionUpdate[] | undefined, +): PermissionSuggestionsByScope { + if (!suggestions || suggestions.length === 0) { + return { session: [], persistent: [] }; + } + + const session: PermissionUpdate[] = []; + const persistent: PermissionUpdate[] = []; + for (const suggestion of suggestions) { + if (SESSION_PERMISSION_DESTINATIONS.has(suggestion.destination)) { + session.push(suggestion); + continue; + } + if (PERSISTENT_PERMISSION_DESTINATIONS.has(suggestion.destination)) { + persistent.push(suggestion); + continue; + } + session.push(suggestion); + } + return { session, persistent }; +} + +export function permissionOptionsFromSuggestions( + suggestions: PermissionUpdate[] | undefined, +): PermissionOption[] { + const scoped = splitPermissionSuggestionsByScope(suggestions); + const hasSessionScoped = scoped.session.length > 0; + const hasPersistentScoped = scoped.persistent.length > 0; + const sessionOnly = hasSessionScoped && !hasPersistentScoped; + + const options: PermissionOption[] = [{ option_id: "allow_once", name: "Allow once", kind: "allow_once" }]; + options.push({ + option_id: sessionOnly ? "allow_session" : "allow_always", + name: sessionOnly ? "Allow for session" : "Always allow", + kind: sessionOnly ? "allow_session" : "allow_always", + }); + options.push({ option_id: "reject_once", name: "Deny", kind: "reject_once" }); + return options; +} + +export function permissionResultFromOutcome( + outcome: PermissionOutcome, + toolCallId: string, + inputData: Record, + suggestions?: PermissionUpdate[], + toolName?: string, +): PermissionResult { + const scopedSuggestions = splitPermissionSuggestionsByScope(suggestions); + + if (outcome.outcome === "selected") { + if (outcome.option_id === "allow_once") { + return { behavior: "allow", updatedInput: inputData, toolUseID: toolCallId }; + } + if (outcome.option_id === "allow_session") { + const sessionSuggestions = scopedSuggestions.session; + const fallbackSuggestions: PermissionUpdate[] | undefined = + sessionSuggestions.length > 0 + ? sessionSuggestions + : toolName + ? [ + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "session", + }, + ] + : undefined; + return { + behavior: "allow", + updatedInput: inputData, + ...(fallbackSuggestions && fallbackSuggestions.length > 0 + ? { updatedPermissions: fallbackSuggestions } + : {}), + toolUseID: toolCallId, + }; + } + if (outcome.option_id === "allow_always") { + const persistentSuggestions = scopedSuggestions.persistent; + const fallbackSuggestions: PermissionUpdate[] | undefined = + persistentSuggestions.length > 0 + ? persistentSuggestions + : toolName + ? [ + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "localSettings", + }, + ] + : undefined; + return { + behavior: "allow", + updatedInput: inputData, + ...(fallbackSuggestions && fallbackSuggestions.length > 0 + ? { updatedPermissions: fallbackSuggestions } + : {}), + toolUseID: toolCallId, + }; + } + return { behavior: "deny", message: "Permission denied", toolUseID: toolCallId }; + } + return { behavior: "deny", message: "Permission cancelled", toolUseID: toolCallId }; +} + diff --git a/claude-code-rust/agent-sdk/src/bridge/session_lifecycle.ts b/claude-code-rust/agent-sdk/src/bridge/session_lifecycle.ts new file mode 100644 index 0000000..bc3e17e --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/session_lifecycle.ts @@ -0,0 +1,703 @@ +import { randomUUID } from "node:crypto"; +import { spawn as spawnChild } from "node:child_process"; +import fs from "node:fs"; +import { + getSessionMessages, + listSessions, + query, + type CanUseTool, + type ModelInfo, + type PermissionMode, + type PermissionResult, + type PermissionUpdate, + type Query, + type SDKUserMessage, + type SettingSource, +} from "@anthropic-ai/claude-agent-sdk"; +import type { + AvailableCommand, + AvailableModel, + BridgeCommand, + ElicitationAction, + ElicitationRequest, + FastModeState, + Json, + PermissionOutcome, + PermissionRequest, + QuestionOutcome, + SessionLaunchSettings, + SessionUpdate, + ToolCall, +} from "../types.js"; +import { AsyncQueue, logPermissionDebug } from "./shared.js"; +import { + formatPermissionUpdates, + permissionOptionsFromSuggestions, + permissionResultFromOutcome, +} from "./permissions.js"; +import { mapSessionMessagesToUpdates } from "./history.js"; +import { + writeEvent, + failConnection, + slashError, + emitSessionUpdate, + emitConnectEvent, + emitSessionsList, + refreshSessionsList, +} from "./events.js"; +import { + ensureToolCallVisible, + setToolCallStatus, +} from "./tool_calls.js"; +import { + requestExitPlanModeApproval, + requestAskUserQuestionAnswers, + EXIT_PLAN_MODE_TOOL_NAME, + ASK_USER_QUESTION_TOOL_NAME, +} from "./user_interaction.js"; +import { mapAvailableAgents, emitAvailableAgentsIfChanged, refreshAvailableAgents } from "./agents.js"; +import { emitAuthRequired, emitFastModeUpdateIfChanged } from "./error_classification.js"; + +export type ConnectEventKind = "connected" | "session_replaced"; + +export type PendingPermission = { + resolve?: (result: PermissionResult) => void; + onOutcome?: (outcome: PermissionOutcome) => void; + toolName: string; + inputData: Record; + suggestions?: PermissionUpdate[]; +}; + +export type PendingQuestion = { + onOutcome: (outcome: QuestionOutcome) => void; + toolName: string; + inputData: Record; +}; + +export type PendingElicitation = { + resolve: (result: { + action: ElicitationAction; + content?: Record; + }) => void; + serverName: string; + elicitationId?: string; +}; + +export type SessionState = { + sessionId: string; + cwd: string; + model: string; + availableModels: AvailableModel[]; + mode: PermissionMode | null; + fastModeState: FastModeState; + query: Query; + input: AsyncQueue; + connected: boolean; + connectEvent: ConnectEventKind; + connectRequestId?: string; + toolCalls: Map; + taskToolUseIds: Map; + pendingPermissions: Map; + pendingQuestions: Map; + pendingElicitations: Map; + mcpStatusRevalidatedAt: Map; + authHintSent: boolean; + lastAvailableAgentsSignature?: string; + lastAssistantError?: string; + sessionsToCloseAfterConnect?: SessionState[]; + resumeUpdates?: SessionUpdate[]; +}; + +export const sessions = new Map(); +const DEFAULT_SETTING_SOURCES: SettingSource[] = ["user", "project", "local"]; +const DEFAULT_MODEL_NAME = "default"; +const DEFAULT_PERMISSION_MODE: PermissionMode = "default"; + +function settingsObjectFromLaunchSettings( + launchSettings: SessionLaunchSettings, +): Record | undefined { + return launchSettings.settings; +} + +export function sessionById(sessionId: string): SessionState | null { + return sessions.get(sessionId) ?? null; +} + +export function updateSessionId(session: SessionState, newSessionId: string): void { + if (session.sessionId === newSessionId) { + return; + } + sessions.delete(session.sessionId); + session.sessionId = newSessionId; + sessions.set(newSessionId, session); +} + +export async function closeSession(session: SessionState): Promise { + session.input.close(); + session.query.close(); + for (const pending of session.pendingPermissions.values()) { + pending.resolve?.({ behavior: "deny", message: "Session closed" }); + pending.onOutcome?.({ outcome: "cancelled" }); + } + session.pendingPermissions.clear(); + for (const pending of session.pendingQuestions.values()) { + pending.onOutcome({ outcome: "cancelled" }); + } + session.pendingQuestions.clear(); + for (const pending of session.pendingElicitations.values()) { + pending.resolve({ action: "cancel" }); + } + session.pendingElicitations.clear(); +} + +export async function closeAllSessions(): Promise { + const active = Array.from(sessions.values()); + sessions.clear(); + await Promise.all(active.map((session) => closeSession(session))); +} + +export async function createSession(params: { + cwd: string; + resume?: string; + launchSettings: SessionLaunchSettings; + connectEvent: ConnectEventKind; + requestId?: string; + sessionsToCloseAfterConnect?: SessionState[]; + resumeUpdates?: SessionUpdate[]; +}): Promise { + const input = new AsyncQueue(); + const provisionalSessionId = params.resume ?? randomUUID(); + const initialModel = initialSessionModel(params.launchSettings); + const initialMode = initialSessionMode(params.launchSettings); + + let session!: SessionState; + const canUseTool: CanUseTool = async (toolName, inputData, options) => { + const toolUseId = options.toolUseID; + if (toolName === EXIT_PLAN_MODE_TOOL_NAME) { + const existing = ensureToolCallVisible(session, toolUseId, toolName, inputData); + return await requestExitPlanModeApproval(session, toolUseId, inputData, existing); + } + logPermissionDebug( + `request tool_use_id=${toolUseId} tool=${toolName} blocked_path=${options.blockedPath ?? ""} ` + + `decision_reason=${options.decisionReason ?? ""} suggestions=${formatPermissionUpdates(options.suggestions)}`, + ); + const existing = ensureToolCallVisible(session, toolUseId, toolName, inputData); + + if (toolName === ASK_USER_QUESTION_TOOL_NAME) { + return await requestAskUserQuestionAnswers( + session, + toolUseId, + inputData, + existing, + ); + } + + const request: PermissionRequest = { + tool_call: existing, + options: permissionOptionsFromSuggestions(options.suggestions), + }; + writeEvent({ event: "permission_request", session_id: session.sessionId, request }); + + return await new Promise((resolve) => { + session.pendingPermissions.set(toolUseId, { + resolve, + toolName, + inputData: inputData, + suggestions: options.suggestions, + }); + }); + }; + + const claudeCodeExecutable = process.env.CLAUDE_CODE_EXECUTABLE; + const sdkDebugFile = process.env.CLAUDE_RS_SDK_DEBUG_FILE; + const enableSdkDebug = process.env.CLAUDE_RS_SDK_DEBUG === "1" || Boolean(sdkDebugFile); + const enableSpawnDebug = process.env.CLAUDE_RS_SDK_SPAWN_DEBUG === "1"; + if (claudeCodeExecutable && !fs.existsSync(claudeCodeExecutable)) { + throw new Error(`CLAUDE_CODE_EXECUTABLE does not exist: ${claudeCodeExecutable}`); + } + + let queryHandle: Query; + try { + queryHandle = query({ + prompt: input, + options: buildQueryOptions({ + cwd: params.cwd, + resume: params.resume, + launchSettings: params.launchSettings, + provisionalSessionId, + input, + canUseTool, + claudeCodeExecutable, + sdkDebugFile, + enableSdkDebug, + enableSpawnDebug, + sessionIdForLogs: () => session.sessionId, + }), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `query() failed: node_executable=${process.execPath}; cwd=${params.cwd}; ` + + `resume=${params.resume ?? ""}; ` + + `CLAUDE_CODE_EXECUTABLE=${claudeCodeExecutable ?? ""}; error=${message}`, + ); + } + + session = { + sessionId: provisionalSessionId, + cwd: params.cwd, + model: initialModel, + availableModels: [], + mode: initialMode, + fastModeState: "off", + query: queryHandle, + input, + connected: false, + connectEvent: params.connectEvent, + connectRequestId: params.requestId, + toolCalls: new Map(), + taskToolUseIds: new Map(), + pendingPermissions: new Map(), + pendingQuestions: new Map(), + pendingElicitations: new Map(), + mcpStatusRevalidatedAt: new Map(), + authHintSent: false, + ...(params.resumeUpdates && params.resumeUpdates.length > 0 + ? { resumeUpdates: params.resumeUpdates } + : {}), + ...(params.sessionsToCloseAfterConnect + ? { sessionsToCloseAfterConnect: params.sessionsToCloseAfterConnect } + : {}), + }; + sessions.set(provisionalSessionId, session); + + // In stream-input mode the SDK may defer init until input arrives. + // Trigger initialization explicitly so the Rust UI can receive `connected` + // before the first user prompt. + void session.query + .initializationResult() + .then((result) => { + session.availableModels = mapAvailableModels(result.models); + if (!session.connected) { + emitConnectEvent(session); + } + // Proactively detect missing auth from account info so the UI can + // show the login hint immediately, without waiting for the first prompt. + const acct = result.account; + const hasCredentials = + (typeof acct.email === "string" && acct.email.trim().length > 0) || + (typeof acct.apiKeySource === "string" && acct.apiKeySource.trim().length > 0); + if (!hasCredentials) { + emitAuthRequired(session); + } + emitFastModeUpdateIfChanged(session, result.fast_mode_state); + + const commands = Array.isArray(result.commands) + ? result.commands.map((command) => ({ + name: command.name, + description: command.description ?? "", + input_hint: command.argumentHint ?? undefined, + })) + : []; + if (commands.length > 0) { + emitSessionUpdate(session.sessionId, { type: "available_commands_update", commands }); + } + emitAvailableAgentsIfChanged(session, mapAvailableAgents(result.agents)); + refreshAvailableAgents(session); + }) + .catch((error) => { + if (session.connected) { + return; + } + const message = error instanceof Error ? error.message : String(error); + failConnection(`agent initialization failed: ${message}`, session.connectRequestId); + session.connectRequestId = undefined; + }); + + void (async () => { + try { + for await (const message of session.query) { + // Lazy import to break circular dependency at module-evaluation time. + const { handleSdkMessage } = await import("./message_handlers.js"); + handleSdkMessage(session, message); + } + if (!session.connected) { + failConnection("agent stream ended before session initialization", params.requestId); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failConnection(`agent stream failed: ${message}`, params.requestId); + } + })(); +} + +type QueryOptionsBuilderParams = { + cwd: string; + resume?: string; + launchSettings: SessionLaunchSettings; + provisionalSessionId: string; + input: AsyncQueue; + canUseTool: CanUseTool; + claudeCodeExecutable?: string; + sdkDebugFile?: string; + enableSdkDebug: boolean; + enableSpawnDebug: boolean; + sessionIdForLogs: () => string; +}; + +function permissionModeFromSettingsValue(rawMode: unknown): PermissionMode | undefined { + if (typeof rawMode !== "string") { + return undefined; + } + switch (rawMode) { + case "default": + case "acceptEdits": + case "bypassPermissions": + case "plan": + case "dontAsk": + return rawMode; + default: + throw new Error(`unsupported launch_settings.settings.permissions.defaultMode: ${rawMode}`); + } +} + +function initialSessionModel(launchSettings: SessionLaunchSettings): string { + const settings = settingsObjectFromLaunchSettings(launchSettings); + const model = typeof settings?.model === "string" ? settings.model.trim() : ""; + return model || DEFAULT_MODEL_NAME; +} + +function startupModelOption( + launchSettings: SessionLaunchSettings, +): { + model?: string; +} { + const settings = settingsObjectFromLaunchSettings(launchSettings); + const model = typeof settings?.model === "string" ? settings.model.trim() : ""; + return model ? { model } : {}; +} + +function initialSessionMode(launchSettings: SessionLaunchSettings): PermissionMode { + const settings = settingsObjectFromLaunchSettings(launchSettings); + const permissions = + settings?.permissions && typeof settings.permissions === "object" && !Array.isArray(settings.permissions) + ? (settings.permissions as Record) + : undefined; + return permissionModeFromSettingsValue(permissions?.defaultMode) ?? DEFAULT_PERMISSION_MODE; +} + +function startupPermissionModeOptions( + launchSettings: SessionLaunchSettings, +): { + permissionMode?: PermissionMode; + allowDangerouslySkipPermissions?: boolean; +} { + const settings = settingsObjectFromLaunchSettings(launchSettings); + const permissions = + settings?.permissions && typeof settings.permissions === "object" && !Array.isArray(settings.permissions) + ? (settings.permissions as Record) + : undefined; + const permissionMode = permissionModeFromSettingsValue(permissions?.defaultMode); + if (!permissionMode) { + return {}; + } + return permissionMode === "bypassPermissions" + ? { + permissionMode, + allowDangerouslySkipPermissions: true, + } + : { permissionMode }; +} + +function systemPromptFromLaunchSettings( + launchSettings: SessionLaunchSettings, +): + | { + type: "preset"; + preset: "claude_code"; + append: string; + } + | undefined { + const language = launchSettings.language?.trim(); + if (!language) { + return undefined; + } + + return { + type: "preset", + preset: "claude_code", + append: + `Always respond to the user in ${language} unless the user explicitly asks for a different language. ` + + `Keep code, shell commands, file paths, API names, tool names, and raw error text unchanged unless the user explicitly asks for translation.`, + }; +} + +export function buildQueryOptions(params: QueryOptionsBuilderParams) { + const systemPrompt = systemPromptFromLaunchSettings(params.launchSettings); + const modelOption = startupModelOption(params.launchSettings); + const permissionModeOptions = startupPermissionModeOptions(params.launchSettings); + return { + cwd: params.cwd, + includePartialMessages: true, + executable: "node" as const, + ...(params.resume ? {} : { sessionId: params.provisionalSessionId }), + ...(params.launchSettings.settings ? { settings: params.launchSettings.settings } : {}), + ...modelOption, + ...permissionModeOptions, + toolConfig: { askUserQuestion: { previewFormat: "markdown" as const } }, + ...(systemPrompt ? { systemPrompt } : {}), + ...(params.launchSettings.agent_progress_summaries !== undefined + ? { agentProgressSummaries: params.launchSettings.agent_progress_summaries } + : {}), + ...(params.claudeCodeExecutable + ? { pathToClaudeCodeExecutable: params.claudeCodeExecutable } + : {}), + ...(params.enableSdkDebug ? { debug: true } : {}), + ...(params.sdkDebugFile ? { debugFile: params.sdkDebugFile } : {}), + stderr: (line: string) => { + if (line.trim().length > 0) { + console.error(`[sdk stderr] ${line}`); + } + }, + ...(params.enableSpawnDebug + ? { + spawnClaudeCodeProcess: (options: { + command: string; + args: string[]; + cwd?: string; + env: Record; + signal: AbortSignal; + }) => { + console.error( + `[sdk spawn] command=${options.command} args=${JSON.stringify(options.args)} cwd=${options.cwd ?? ""}`, + ); + const child = spawnChild(options.command, options.args, { + cwd: options.cwd, + env: options.env, + signal: options.signal, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + child.on("error", (error) => { + console.error( + `[sdk spawn error] code=${(error as NodeJS.ErrnoException).code ?? ""} message=${error.message}`, + ); + }); + return child; + }, + } + : {}), + // Match claude-agent-acp defaults to avoid emitting an empty + // --setting-sources argument. + settingSources: DEFAULT_SETTING_SOURCES, + resume: params.resume, + canUseTool: params.canUseTool, + onElicitation: async (request: { + mode?: string; + serverName?: string; + message?: string; + url?: string; + elicitationId?: string; + requestedSchema?: Record; + }) => { + const requestId = randomUUID(); + const mode = + request.mode === "form" || request.mode === "url" + ? request.mode + : typeof request.url === "string" && request.url.trim().length > 0 + ? "url" + : "form"; + const normalized: ElicitationRequest = { + request_id: requestId, + server_name: + typeof request.serverName === "string" && request.serverName.trim().length > 0 + ? request.serverName + : "unknown", + message: + typeof request.message === "string" && request.message.trim().length > 0 + ? request.message + : "", + mode, + ...(typeof request.url === "string" && request.url.trim().length > 0 + ? { url: request.url } + : {}), + ...(typeof request.elicitationId === "string" && request.elicitationId.trim().length > 0 + ? { elicitation_id: request.elicitationId } + : {}), + ...(request.requestedSchema + ? { requested_schema: request.requestedSchema as Record } + : {}), + }; + writeEvent({ + event: "elicitation_request", + session_id: params.sessionIdForLogs(), + request: normalized, + }); + return await new Promise<{ + action: ElicitationAction; + content?: Record; + }>((resolve) => { + const currentSession = sessions.get(params.sessionIdForLogs()); + if (!currentSession) { + resolve({ action: "cancel" }); + return; + } + currentSession.pendingElicitations.set(requestId, { + resolve, + serverName: normalized.server_name, + elicitationId: normalized.elicitation_id, + }); + }); + }, + }; +} + +export function mapAvailableModels(models: ModelInfo[] | undefined): AvailableModel[] { + if (!Array.isArray(models)) { + return []; + } + + return models + .filter((entry): entry is ModelInfo & { value: string; displayName: string } => { + return ( + typeof entry?.value === "string" && + entry.value.trim().length > 0 && + typeof entry.displayName === "string" && + entry.displayName.trim().length > 0 + ); + }) + .map((entry) => ({ + id: entry.value, + display_name: entry.displayName, + supports_effort: entry.supportsEffort === true, + supported_effort_levels: Array.isArray(entry.supportedEffortLevels) + ? entry.supportedEffortLevels.filter( + (level): level is "low" | "medium" | "high" => + level === "low" || level === "medium" || level === "high", + ) + : [], + ...(typeof entry.supportsAdaptiveThinking === "boolean" + ? { supports_adaptive_thinking: entry.supportsAdaptiveThinking } + : {}), + ...(typeof entry.supportsFastMode === "boolean" + ? { supports_fast_mode: entry.supportsFastMode } + : {}), + ...(typeof entry.supportsAutoMode === "boolean" + ? { supports_auto_mode: entry.supportsAutoMode } + : {}), + ...(typeof entry.description === "string" && entry.description.trim().length > 0 + ? { description: entry.description } + : {}), + })); +} + +export function handlePermissionResponse(command: Extract): void { + const session = sessionById(command.session_id); + if (!session) { + logPermissionDebug( + `response dropped: unknown session session_id=${command.session_id} tool_call_id=${command.tool_call_id}`, + ); + return; + } + const resolver = session.pendingPermissions.get(command.tool_call_id); + if (!resolver) { + logPermissionDebug( + `response dropped: no pending resolver session_id=${command.session_id} tool_call_id=${command.tool_call_id}`, + ); + return; + } + session.pendingPermissions.delete(command.tool_call_id); + + const outcome = command.outcome as PermissionOutcome; + if (resolver.onOutcome) { + resolver.onOutcome(outcome); + return; + } + if (!resolver.resolve) { + logPermissionDebug( + `response dropped: resolver missing callback session_id=${command.session_id} tool_call_id=${command.tool_call_id}`, + ); + return; + } + const selectedOption = outcome.outcome === "selected" ? outcome.option_id : "cancelled"; + logPermissionDebug( + `response session_id=${command.session_id} tool_call_id=${command.tool_call_id} tool=${resolver.toolName} ` + + `selected=${selectedOption} suggestions=${formatPermissionUpdates(resolver.suggestions)}`, + ); + if ( + outcome.outcome === "selected" && + (outcome.option_id === "allow_once" || + outcome.option_id === "allow_session" || + outcome.option_id === "allow_always") + ) { + setToolCallStatus(session, command.tool_call_id, "in_progress"); + } else if (outcome.outcome === "selected") { + setToolCallStatus(session, command.tool_call_id, "failed", "Permission denied"); + } else { + setToolCallStatus(session, command.tool_call_id, "failed", "Permission cancelled"); + } + + const permissionResult = permissionResultFromOutcome( + outcome, + command.tool_call_id, + resolver.inputData, + resolver.suggestions, + resolver.toolName, + ); + if (permissionResult.behavior === "allow") { + logPermissionDebug( + `result tool_call_id=${command.tool_call_id} behavior=allow updated_permissions=` + + `${formatPermissionUpdates(permissionResult.updatedPermissions)}`, + ); + } else { + logPermissionDebug( + `result tool_call_id=${command.tool_call_id} behavior=deny message=${permissionResult.message}`, + ); + } + resolver.resolve(permissionResult); +} + +export function handleQuestionResponse(command: Extract): void { + const session = sessionById(command.session_id); + if (!session) { + logPermissionDebug( + `question response dropped: unknown session session_id=${command.session_id} tool_call_id=${command.tool_call_id}`, + ); + return; + } + const resolver = session.pendingQuestions.get(command.tool_call_id); + if (!resolver) { + logPermissionDebug( + `question response dropped: no pending resolver session_id=${command.session_id} tool_call_id=${command.tool_call_id}`, + ); + return; + } + session.pendingQuestions.delete(command.tool_call_id); + resolver.onOutcome(command.outcome); +} + +export function handleElicitationResponse( + command: Extract, +): void { + const session = sessionById(command.session_id); + if (!session) { + console.error( + `[sdk warn] elicitation response dropped: unknown session ` + + `session_id=${command.session_id} request_id=${command.elicitation_request_id}`, + ); + return; + } + const pending = session.pendingElicitations.get(command.elicitation_request_id); + if (!pending) { + console.error( + `[sdk warn] elicitation response dropped: no pending request ` + + `session_id=${command.session_id} request_id=${command.elicitation_request_id}`, + ); + return; + } + session.pendingElicitations.delete(command.elicitation_request_id); + pending.resolve({ + action: command.action, + ...(command.content ? { content: command.content } : {}), + }); +} diff --git a/claude-code-rust/agent-sdk/src/bridge/shared.ts b/claude-code-rust/agent-sdk/src/bridge/shared.ts new file mode 100644 index 0000000..d62f19a --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/shared.ts @@ -0,0 +1,62 @@ +export function asRecordOrNull(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +export class AsyncQueue implements AsyncIterable { + private readonly items: T[] = []; + private readonly waiters: Array<(result: IteratorResult) => void> = []; + private closed = false; + + enqueue(item: T): void { + if (this.closed) { + return; + } + const waiter = this.waiters.shift(); + if (waiter) { + waiter({ value: item, done: false }); + return; + } + this.items.push(item); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + while (this.waiters.length > 0) { + const waiter = this.waiters.shift(); + waiter?.({ value: undefined, done: true }); + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: async (): Promise> => { + if (this.items.length > 0) { + const value = this.items.shift(); + return { value: value as T, done: false }; + } + if (this.closed) { + return { value: undefined, done: true }; + } + return await new Promise>((resolve) => { + this.waiters.push(resolve); + }); + }, + }; + } +} + +const permissionDebugEnabled = + process.env.CLAUDE_RS_SDK_PERMISSION_DEBUG === "1" || process.env.CLAUDE_RS_SDK_DEBUG === "1"; + +export function logPermissionDebug(message: string): void { + if (!permissionDebugEnabled) { + return; + } + console.error(`[perm debug] ${message}`); +} diff --git a/claude-code-rust/agent-sdk/src/bridge/state_parsing.ts b/claude-code-rust/agent-sdk/src/bridge/state_parsing.ts new file mode 100644 index 0000000..cb1a275 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/state_parsing.ts @@ -0,0 +1,84 @@ +import type { FastModeState, RateLimitStatus, SessionUpdate } from "../types.js"; +import { asRecordOrNull } from "./shared.js"; + +export function numberField(record: Record, ...keys: string[]): number | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return undefined; +} + +export function parseFastModeState(value: unknown): FastModeState | null { + if (value === "off" || value === "cooldown" || value === "on") { + return value; + } + return null; +} + +export function parseRateLimitStatus(value: unknown): RateLimitStatus | null { + if (value === "allowed" || value === "allowed_warning" || value === "rejected") { + return value; + } + return null; +} + +export function buildRateLimitUpdate( + rateLimitInfo: unknown, +): Extract | null { + const info = asRecordOrNull(rateLimitInfo); + if (!info) { + return null; + } + + const status = parseRateLimitStatus(info.status); + if (!status) { + return null; + } + + const update: Extract = { + type: "rate_limit_update", + status, + }; + + const resetsAt = numberField(info, "resetsAt"); + if (resetsAt !== undefined) { + update.resets_at = resetsAt; + } + + const utilization = numberField(info, "utilization"); + if (utilization !== undefined) { + update.utilization = utilization; + } + + if (typeof info.rateLimitType === "string" && info.rateLimitType.length > 0) { + update.rate_limit_type = info.rateLimitType; + } + + const overageStatus = parseRateLimitStatus(info.overageStatus); + if (overageStatus) { + update.overage_status = overageStatus; + } + + const overageResetsAt = numberField(info, "overageResetsAt"); + if (overageResetsAt !== undefined) { + update.overage_resets_at = overageResetsAt; + } + + if (typeof info.overageDisabledReason === "string" && info.overageDisabledReason.length > 0) { + update.overage_disabled_reason = info.overageDisabledReason; + } + + if (typeof info.isUsingOverage === "boolean") { + update.is_using_overage = info.isUsingOverage; + } + + const surpassedThreshold = numberField(info, "surpassedThreshold"); + if (surpassedThreshold !== undefined) { + update.surpassed_threshold = surpassedThreshold; + } + + return update; +} diff --git a/claude-code-rust/agent-sdk/src/bridge/tool_calls.ts b/claude-code-rust/agent-sdk/src/bridge/tool_calls.ts new file mode 100644 index 0000000..0ecc33b --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/tool_calls.ts @@ -0,0 +1,214 @@ +import type { PlanEntry, ToolCall, ToolCallUpdateFields } from "../types.js"; +import { emitSessionUpdate } from "./events.js"; +import type { SessionState } from "./session_lifecycle.js"; +import { buildToolResultFields, createToolCall } from "./tooling.js"; + +export function emitToolCall(session: SessionState, toolUseId: string, name: string, input: Record): void { + const toolCall = createToolCall(toolUseId, name, input); + const status: ToolCall["status"] = "in_progress"; + toolCall.status = status; + + const existing = session.toolCalls.get(toolUseId); + if (!existing) { + session.toolCalls.set(toolUseId, toolCall); + emitSessionUpdate(session.sessionId, { type: "tool_call", tool_call: toolCall }); + return; + } + + const fields: ToolCallUpdateFields = { + title: toolCall.title, + kind: toolCall.kind, + status, + raw_input: toolCall.raw_input, + locations: toolCall.locations, + meta: toolCall.meta, + }; + if (toolCall.content.length > 0) { + fields.content = toolCall.content; + } + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields }, + }); + + existing.title = toolCall.title; + existing.kind = toolCall.kind; + existing.status = status; + existing.raw_input = toolCall.raw_input; + existing.locations = toolCall.locations; + existing.meta = toolCall.meta; + if (toolCall.content.length > 0) { + existing.content = toolCall.content; + } +} + +export function ensureToolCallVisible( + session: SessionState, + toolUseId: string, + toolName: string, + input: Record, +): ToolCall { + const existing = session.toolCalls.get(toolUseId); + if (existing) { + return existing; + } + const toolCall = createToolCall(toolUseId, toolName, input); + session.toolCalls.set(toolUseId, toolCall); + emitSessionUpdate(session.sessionId, { type: "tool_call", tool_call: toolCall }); + return toolCall; +} + +export function emitPlanIfTodoWrite(session: SessionState, name: string, input: Record): void { + if (name !== "TodoWrite" || !Array.isArray(input.todos)) { + return; + } + const entries: PlanEntry[] = input.todos + .map((todo) => { + if (!todo || typeof todo !== "object") { + return null; + } + const todoObj = todo as Record; + const content = typeof todoObj.content === "string" ? todoObj.content : ""; + const status = typeof todoObj.status === "string" ? todoObj.status : "pending"; + if (!content) { + return null; + } + return { content, status, active_form: status }; + }) + .filter((entry): entry is PlanEntry => entry !== null); + + if (entries.length > 0) { + emitSessionUpdate(session.sessionId, { type: "plan", entries }); + } +} + +export function emitToolResultUpdate( + session: SessionState, + toolUseId: string, + isError: boolean, + rawContent: unknown, + rawResult: unknown = rawContent, +): void { + const base = session.toolCalls.get(toolUseId); + const fields = buildToolResultFields(isError, rawContent, base, rawResult); + const update = { tool_call_id: toolUseId, fields }; + emitSessionUpdate(session.sessionId, { type: "tool_call_update", tool_call_update: update }); + + if (base) { + base.status = fields.status ?? base.status; + if (fields.raw_output) { + base.raw_output = fields.raw_output; + } + if (fields.content) { + base.content = fields.content; + } + if (fields.output_metadata) { + base.output_metadata = fields.output_metadata; + } + } +} + +export function finalizeOpenToolCalls(session: SessionState, status: "completed" | "failed"): void { + for (const [toolUseId, toolCall] of session.toolCalls) { + if (toolCall.status !== "pending" && toolCall.status !== "in_progress") { + continue; + } + const fields: ToolCallUpdateFields = { status }; + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields }, + }); + toolCall.status = status; + } +} + +export function emitToolProgressUpdate(session: SessionState, toolUseId: string, toolName: string): void { + const existing = session.toolCalls.get(toolUseId); + if (!existing) { + emitToolCall(session, toolUseId, toolName, {}); + return; + } + if ( + existing.status === "in_progress" || + existing.status === "completed" || + existing.status === "failed" + ) { + return; + } + + const fields: ToolCallUpdateFields = { status: "in_progress" }; + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields }, + }); + existing.status = "in_progress"; +} + +export function emitToolSummaryUpdate(session: SessionState, toolUseId: string, summary: string): void { + const base = session.toolCalls.get(toolUseId); + if (!base) { + return; + } + const fields: ToolCallUpdateFields = { + status: base.status === "failed" ? "failed" : "completed", + raw_output: summary, + content: [{ type: "content", content: { type: "text", text: summary } }], + }; + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields }, + }); + base.status = fields.status ?? base.status; + base.raw_output = summary; +} + +export function setToolCallStatus( + session: SessionState, + toolUseId: string, + status: "pending" | "in_progress" | "completed" | "failed", + message?: string, +): void { + const base = session.toolCalls.get(toolUseId); + if (!base) { + return; + } + + const fields: ToolCallUpdateFields = { status }; + if (message && message.length > 0) { + fields.raw_output = message; + fields.content = [{ type: "content", content: { type: "text", text: message } }]; + } + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields }, + }); + base.status = status; + if (fields.raw_output) { + base.raw_output = fields.raw_output; + } +} + +export function resolveTaskToolUseId(session: SessionState, msg: Record): string { + const direct = typeof msg.tool_use_id === "string" ? msg.tool_use_id : ""; + if (direct) { + return direct; + } + const taskId = typeof msg.task_id === "string" ? msg.task_id : ""; + if (!taskId) { + return ""; + } + return session.taskToolUseIds.get(taskId) ?? ""; +} + +export function taskProgressText(msg: Record): string { + const summary = typeof msg.summary === "string" ? msg.summary.trim() : ""; + if (summary) { + return summary; + } + const description = typeof msg.description === "string" ? msg.description : ""; + const lastTool = typeof msg.last_tool_name === "string" ? msg.last_tool_name : ""; + if (description && lastTool) { + return `${description} (last tool: ${lastTool})`; + } + return description || lastTool; +} diff --git a/claude-code-rust/agent-sdk/src/bridge/tooling.ts b/claude-code-rust/agent-sdk/src/bridge/tooling.ts new file mode 100644 index 0000000..605d43f --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/tooling.ts @@ -0,0 +1,675 @@ +import type { Json, ToolCall, ToolCallUpdateFields } from "../types.js"; +import { asRecordOrNull } from "./shared.js"; +import { CACHE_SPLIT_POLICY, previewKilobyteLabel } from "./cache_policy.js"; + +export const TOOL_RESULT_TYPES = new Set([ + "tool_result", + "tool_search_tool_result", + "web_fetch_tool_result", + "web_search_tool_result", + "code_execution_tool_result", + "bash_code_execution_tool_result", + "text_editor_code_execution_tool_result", + "mcp_tool_result", +]); + +export function isToolUseBlockType(blockType: string): boolean { + return blockType === "tool_use" || blockType === "server_tool_use" || blockType === "mcp_tool_use"; +} + +export function normalizeToolKind(name: string): string { + switch (name) { + case "Bash": + return "execute"; + case "Read": + case "ReadMcpResource": + return "read"; + case "Write": + case "Edit": + return "edit"; + case "Delete": + return "delete"; + case "Move": + return "move"; + case "Glob": + case "Grep": + return "search"; + case "WebFetch": + return "fetch"; + case "TodoWrite": + return "other"; + case "Task": + case "Agent": + return "think"; + case "ExitPlanMode": + return "switch_mode"; + default: + return "think"; + } +} + +export function toolTitle(name: string, input: Record): string { + if (name === "Bash") { + const command = typeof input.command === "string" ? input.command : ""; + return command || "Terminal"; + } + if (name === "Glob") { + const pattern = typeof input.pattern === "string" ? input.pattern : ""; + const path = typeof input.path === "string" ? input.path : ""; + if (pattern && path) { + return `Glob ${pattern} in ${path}`; + } + if (pattern) { + return `Glob ${pattern}`; + } + if (path) { + return `Glob ${path}`; + } + } + if (name === "WebFetch") { + const url = typeof input.url === "string" ? input.url : ""; + if (url) { + return `WebFetch ${url}`; + } + } + if (name === "WebSearch") { + const query = typeof input.query === "string" ? input.query : ""; + if (query) { + return `WebSearch ${query}`; + } + } + if ((name === "Read" || name === "Write" || name === "Edit") && typeof input.file_path === "string") { + return `${name} ${input.file_path}`; + } + if (name === "ReadMcpResource") { + const uri = typeof input.uri === "string" ? input.uri : ""; + const server = typeof input.server === "string" ? input.server : ""; + if (server && uri) { + return `ReadMcpResource ${server} ${uri}`; + } + if (uri) { + return `ReadMcpResource ${uri}`; + } + } + return name; +} + +function editDiffContent(name: string, input: Record): ToolCall["content"] { + const filePath = typeof input.file_path === "string" ? input.file_path : ""; + if (!filePath) { + return []; + } + + if (name === "Edit") { + const oldText = typeof input.old_string === "string" ? input.old_string : ""; + const newText = typeof input.new_string === "string" ? input.new_string : ""; + if (!oldText && !newText) { + return []; + } + return [{ type: "diff", old_path: filePath, new_path: filePath, old: oldText, new: newText }]; + } + + if (name === "Write") { + const newText = typeof input.content === "string" ? input.content : ""; + if (!newText) { + return []; + } + return [{ type: "diff", old_path: filePath, new_path: filePath, old: "", new: newText }]; + } + + return []; +} + +export function createToolCall(toolUseId: string, name: string, input: Record): ToolCall { + return { + tool_call_id: toolUseId, + title: toolTitle(name, input), + kind: normalizeToolKind(name), + status: "pending", + content: editDiffContent(name, input), + raw_input: input as unknown as Json, + locations: typeof input.file_path === "string" ? [{ path: input.file_path }] : [], + meta: { + claudeCode: { + toolName: name, + }, + }, + }; +} + +function resultRecordCandidates(rawResult: unknown, rawContent: unknown): Record[] { + const candidates: Record[] = []; + + const pushRecord = (value: unknown): void => { + const record = asRecordOrNull(value); + if (record) { + candidates.push(record); + } + }; + + const pushNestedRecords = (value: unknown): void => { + const record = asRecordOrNull(value); + if (!record) { + return; + } + pushRecord(record.result); + pushRecord(record.data); + pushRecord(record.content); + }; + + pushRecord(rawResult); + pushNestedRecords(rawResult); + pushRecord(rawContent); + pushNestedRecords(rawContent); + + return candidates; +} + +function parseJsonCandidate(value: unknown): unknown { + const text = typeof value === "string" ? value : extractText(value); + const trimmed = text.trim(); + if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { + return undefined; + } + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} + +function pushStructuredRecordCandidates( + candidates: Record[], + value: unknown, +): void { + const record = asRecordOrNull(value); + if (!record) { + return; + } + candidates.push(record); + + const nestedResult = asRecordOrNull(record.result); + if (nestedResult) { + candidates.push(nestedResult); + } + const nestedData = asRecordOrNull(record.data); + if (nestedData) { + candidates.push(nestedData); + } + const nestedContent = asRecordOrNull(record.content); + if (nestedContent) { + candidates.push(nestedContent); + } +} + +function mcpResourceContentFromResult(rawResult: unknown, rawContent: unknown): ToolCall["content"] { + const candidates: Record[] = []; + for (const candidate of [rawResult, rawContent, parseJsonCandidate(rawResult), parseJsonCandidate(rawContent)]) { + pushStructuredRecordCandidates(candidates, candidate); + } + + for (const candidate of candidates) { + const contents = Array.isArray(candidate.contents) ? candidate.contents : null; + if (!contents || contents.length === 0) { + continue; + } + + const mapped: ToolCall["content"] = []; + for (const entry of contents) { + const record = asRecordOrNull(entry); + if (!record) { + continue; + } + const uri = typeof record.uri === "string" ? record.uri : ""; + if (!uri) { + continue; + } + const text = + typeof record.text === "string" && record.text.length > 0 ? record.text : undefined; + const mimeType = + typeof record.mimeType === "string" && record.mimeType.trim().length > 0 + ? record.mimeType.trim() + : undefined; + const blobSavedTo = + typeof record.blobSavedTo === "string" && record.blobSavedTo.trim().length > 0 + ? record.blobSavedTo.trim() + : undefined; + if (!text && !blobSavedTo) { + continue; + } + mapped.push({ + type: "mcp_resource", + uri, + ...(mimeType ? { mime_type: mimeType } : {}), + ...(text ? { text } : {}), + ...(blobSavedTo ? { blob_saved_to: blobSavedTo } : {}), + }); + } + + if (mapped.length > 0) { + return mapped; + } + } + + return []; +} + +function extractToolOutputMetadata( + toolName: string, + rawResult: unknown, + rawContent: unknown, +): import("../types.js").ToolOutputMetadata | undefined { + const candidates = resultRecordCandidates(rawResult, rawContent); + + if (toolName === "Bash") { + for (const candidate of candidates) { + const hasAssistantAutoBackgrounded = typeof candidate.assistantAutoBackgrounded === "boolean"; + const hasTokenSaverOutput = + typeof candidate.tokenSaverOutput === "string" && candidate.tokenSaverOutput.length > 0; + if (hasAssistantAutoBackgrounded || hasTokenSaverOutput) { + const bashMetadata: import("../types.js").BashOutputMetadata = {}; + if (hasAssistantAutoBackgrounded) { + bashMetadata.assistant_auto_backgrounded = candidate.assistantAutoBackgrounded as boolean; + } + if (hasTokenSaverOutput) { + bashMetadata.token_saver_active = true; + } + return { + bash: bashMetadata, + }; + } + } + return undefined; + } + + if (toolName === "ExitPlanMode") { + for (const candidate of candidates) { + if (typeof candidate.isUltraplan === "boolean") { + return { exit_plan_mode: { is_ultraplan: candidate.isUltraplan } }; + } + } + return undefined; + } + + if (toolName === "TodoWrite") { + for (const candidate of candidates) { + if (typeof candidate.verificationNudgeNeeded === "boolean") { + return { + todo_write: { verification_nudge_needed: candidate.verificationNudgeNeeded }, + }; + } + } + } + + return undefined; +} + +export function extractText(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value + .map((entry) => { + if (typeof entry === "string") { + return entry; + } + if (entry && typeof entry === "object" && "text" in entry && typeof entry.text === "string") { + return entry.text; + } + return ""; + }) + .filter((part) => part.length > 0) + .join("\n"); + } + if (value && typeof value === "object" && "text" in value && typeof value.text === "string") { + return value.text; + } + return ""; +} + +const PERSISTED_OUTPUT_OPEN_TAG = ""; +const PERSISTED_OUTPUT_CLOSE_TAG = ""; +const EXPECTED_PREVIEW_LINE = `preview (first ${previewKilobyteLabel(CACHE_SPLIT_POLICY).toLowerCase()}):`; + +function extractPersistedOutputInnerText(text: string): string | null { + const lower = text.toLowerCase(); + const openIdx = lower.indexOf(PERSISTED_OUTPUT_OPEN_TAG); + if (openIdx < 0) { + return null; + } + const bodyStart = openIdx + PERSISTED_OUTPUT_OPEN_TAG.length; + const closeIdx = lower.indexOf(PERSISTED_OUTPUT_CLOSE_TAG, bodyStart); + if (closeIdx < 0) { + return null; + } + return text.slice(bodyStart, closeIdx); +} + +function persistedOutputFirstLine(text: string): string | null { + const inner = extractPersistedOutputInnerText(text); + if (inner === null) { + return null; + } + + for (const line of inner.split(/\r?\n/)) { + const cleaned = line.replace(/^[\s|│┃║]+/u, "").trim(); + if (cleaned.length > 0) { + if (cleaned.toLowerCase() === EXPECTED_PREVIEW_LINE) { + continue; + } + return cleaned; + } + } + return null; +} + +/** + * Replace verbose SDK-internal tool rejection messages with short user-facing text. + * The SDK sends these as tool result content meant for Claude, not for the user. + */ +const USER_REJECTED_TOOL_USE_EXACT = + "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."; +const USER_REJECTED_TOOL_USE_PREFIX = + "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:"; +const PERMISSION_DENIED_TOOL_USE_EXACT = + "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). Try a different approach or report the limitation to complete your task."; +const PERMISSION_DENIED_TOOL_USE_PREFIX = + "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). The user said:"; + +function sanitizeSdkRejectionText(text: string): string { + const normalized = text.trim(); + if ( + normalized === USER_REJECTED_TOOL_USE_EXACT || + normalized.startsWith(USER_REJECTED_TOOL_USE_PREFIX) + ) { + return "Cancelled by user."; + } + if ( + normalized === PERMISSION_DENIED_TOOL_USE_EXACT || + normalized.startsWith(PERMISSION_DENIED_TOOL_USE_PREFIX) + ) { + return "Permission denied."; + } + return text; +} + +export function normalizeToolResultText(value: unknown, isError = false): string { + const text = extractText(value); + if (!text) { + return ""; + } + const persistedLine = persistedOutputFirstLine(text); + const normalized = persistedLine || text; + if (!isError) { + return normalized; + } + return sanitizeSdkRejectionText(normalized); +} + +function resolveToolName(toolCall: ToolCall | undefined): string { + const meta = asRecordOrNull(toolCall?.meta); + const claudeCode = asRecordOrNull(meta?.claudeCode); + const toolName = claudeCode?.toolName; + return typeof toolName === "string" ? toolName : ""; +} + +function writeDiffFromInput(rawInput: Json | undefined): ToolCall["content"] { + const input = asRecordOrNull(rawInput); + if (!input) { + return []; + } + const filePath = typeof input.file_path === "string" ? input.file_path : ""; + const content = typeof input.content === "string" ? input.content : ""; + if (!filePath || !content) { + return []; + } + return [{ type: "diff", old_path: filePath, new_path: filePath, old: "", new: content }]; +} + +function editDiffFromInput(rawInput: Json | undefined): ToolCall["content"] { + const input = asRecordOrNull(rawInput); + if (!input) { + return []; + } + const filePath = typeof input.file_path === "string" ? input.file_path : ""; + const oldText = + typeof input.old_string === "string" + ? input.old_string + : typeof input.oldString === "string" + ? input.oldString + : ""; + const newText = + typeof input.new_string === "string" + ? input.new_string + : typeof input.newString === "string" + ? input.newString + : ""; + if (!filePath || (!oldText && !newText)) { + return []; + } + return [{ type: "diff", old_path: filePath, new_path: filePath, old: oldText, new: newText }]; +} + +function writeDiffFromResult(rawContent: unknown): ToolCall["content"] { + const candidates = Array.isArray(rawContent) ? rawContent : [rawContent]; + for (const candidate of candidates) { + const record = asRecordOrNull(candidate); + if (!record) { + continue; + } + const filePath = + typeof record.filePath === "string" + ? record.filePath + : typeof record.file_path === "string" + ? record.file_path + : ""; + const content = typeof record.content === "string" ? record.content : ""; + const originalRaw = + "originalFile" in record ? record.originalFile : "original_file" in record ? record.original_file : undefined; + const gitDiff = asRecordOrNull(record.gitDiff); + const repository = + typeof gitDiff?.repository === "string" && gitDiff.repository.trim().length > 0 + ? gitDiff.repository.trim() + : undefined; + if (!filePath || !content || originalRaw === undefined) { + continue; + } + const original = typeof originalRaw === "string" ? originalRaw : originalRaw === null ? "" : ""; + return [ + { + type: "diff", + old_path: filePath, + new_path: filePath, + old: original, + new: content, + ...(repository ? { repository } : {}), + }, + ]; + } + return []; +} + +function editDiffFromResult(rawResult: unknown, rawInput: Json | undefined): ToolCall["content"] { + const input = asRecordOrNull(rawInput); + const filePath = typeof input?.file_path === "string" ? input.file_path : ""; + const oldText = + typeof input?.old_string === "string" + ? input.old_string + : typeof input?.oldString === "string" + ? input.oldString + : ""; + const newText = + typeof input?.new_string === "string" + ? input.new_string + : typeof input?.newString === "string" + ? input.newString + : ""; + if (!filePath || (!oldText && !newText)) { + return []; + } + + for (const candidate of resultRecordCandidates(rawResult, undefined)) { + const candidatePath = + typeof candidate.filePath === "string" + ? candidate.filePath + : typeof candidate.file_path === "string" + ? candidate.file_path + : ""; + const gitDiff = asRecordOrNull(candidate.gitDiff); + if (!candidatePath && !gitDiff) { + continue; + } + if (candidatePath && candidatePath !== filePath) { + continue; + } + const repository = + typeof gitDiff?.repository === "string" && gitDiff.repository.trim().length > 0 + ? gitDiff.repository.trim() + : undefined; + return [ + { + type: "diff", + old_path: filePath, + new_path: filePath, + old: oldText, + new: newText, + ...(repository ? { repository } : {}), + }, + ]; + } + + return editDiffFromInput(rawInput); +} + +function findBashResultRecord( + rawResult: unknown, + rawContent: unknown, +): Record | undefined { + return resultRecordCandidates(rawResult, rawContent).find( + (candidate) => + "stdout" in candidate || + "stderr" in candidate || + "backgroundTaskId" in candidate || + "backgroundedByUser" in candidate || + "assistantAutoBackgrounded" in candidate || + "tokenSaverOutput" in candidate, + ); +} + +function bashBackgroundMessage(record: Record): string { + const backgroundTaskId = + typeof record.backgroundTaskId === "string" ? record.backgroundTaskId : ""; + if (!backgroundTaskId) { + return ""; + } + if (record.assistantAutoBackgrounded === true) { + return `Command was auto-backgrounded by assistant mode with ID: ${backgroundTaskId}.`; + } + if (record.backgroundedByUser === true) { + return `Command was backgrounded by user with ID: ${backgroundTaskId}.`; + } + return `Command is running in background with ID: ${backgroundTaskId}.`; +} + +function buildBashDisplayOutput(record: Record): string { + const segments: string[] = []; + const stdout = typeof record.stdout === "string" ? record.stdout : ""; + const stderr = typeof record.stderr === "string" ? record.stderr : ""; + if (stdout) { + segments.push(stdout); + } + if (stderr) { + segments.push(stderr); + } + if (record.interrupted === true) { + segments.push("Command was aborted before completion."); + } + const backgroundMessage = bashBackgroundMessage(record); + if (backgroundMessage) { + segments.push(backgroundMessage); + } + return segments.join("\n"); +} + +export function buildToolResultFields( + isError: boolean, + rawContent: unknown, + base?: ToolCall, + rawResult?: unknown, +): ToolCallUpdateFields { + const toolName = resolveToolName(base); + const bashResultRecord = toolName === "Bash" ? findBashResultRecord(rawResult, rawContent) : undefined; + const normalizedRawOutput = normalizeToolResultText(rawContent, isError); + const rawOutput = bashResultRecord + ? buildBashDisplayOutput(bashResultRecord) + : normalizedRawOutput || JSON.stringify(rawContent); + const fields: ToolCallUpdateFields = { + status: isError ? "failed" : "completed", + }; + if (rawOutput) { + fields.raw_output = rawOutput; + } + const outputMetadata = extractToolOutputMetadata(toolName, rawResult, rawContent); + if (outputMetadata) { + fields.output_metadata = outputMetadata; + } + + if (!isError && toolName === "Write") { + const structuredDiff = writeDiffFromResult(rawContent); + if (structuredDiff.length > 0) { + fields.content = structuredDiff; + return fields; + } + const inputDiff = writeDiffFromInput(base?.raw_input); + if (inputDiff.length > 0) { + fields.content = inputDiff; + return fields; + } + } + + if (!isError && toolName === "Edit") { + const structuredDiff = editDiffFromResult(rawResult, base?.raw_input); + if (structuredDiff.length > 0) { + fields.content = structuredDiff; + return fields; + } + if (base?.content.some((entry) => entry.type === "diff")) { + return fields; + } + } + + if (!isError && toolName === "ReadMcpResource") { + const structuredResourceContent = mcpResourceContentFromResult(rawResult, rawContent); + if (structuredResourceContent.length > 0) { + fields.content = structuredResourceContent; + return fields; + } + } + + if (rawOutput) { + fields.content = [{ type: "content", content: { type: "text", text: rawOutput } }]; + } + return fields; +} + +export function unwrapToolUseResult(rawResult: unknown): { isError: boolean; content: unknown } { + if (!rawResult || typeof rawResult !== "object") { + return { isError: false, content: rawResult }; + } + const record = rawResult as Record; + const isError = + (typeof record.is_error === "boolean" && record.is_error) || + (typeof record.error === "boolean" && record.error); + if ("content" in record) { + return { isError: Boolean(isError), content: record.content }; + } + if ("result" in record) { + return { isError: Boolean(isError), content: record.result }; + } + if ("text" in record) { + return { isError: Boolean(isError), content: record.text }; + } + return { isError: Boolean(isError), content: rawResult }; +} + diff --git a/claude-code-rust/agent-sdk/src/bridge/user_interaction.ts b/claude-code-rust/agent-sdk/src/bridge/user_interaction.ts new file mode 100644 index 0000000..59dc923 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/bridge/user_interaction.ts @@ -0,0 +1,310 @@ +import type { PermissionResult } from "@anthropic-ai/claude-agent-sdk"; +import type { + Json, + PermissionOption, + PermissionOutcome, + PermissionRequest, + QuestionAnnotation, + QuestionOption, + QuestionOutcome, + QuestionPrompt, + QuestionRequest, + ToolCall, + ToolCallUpdateFields, +} from "../types.js"; +import { asRecordOrNull } from "./shared.js"; +import { writeEvent, emitSessionUpdate } from "./events.js"; +import { setToolCallStatus } from "./tool_calls.js"; +import type { SessionState } from "./session_lifecycle.js"; + +export type AskUserQuestionOption = { + label: string; + description: string; + preview?: string; +}; + +export type AskUserQuestionPrompt = { + question: string; + header: string; + multiSelect: boolean; + options: AskUserQuestionOption[]; +}; + +export const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion"; +export const QUESTION_CHOICE_KIND = "question_choice"; + +export const EXIT_PLAN_MODE_TOOL_NAME = "ExitPlanMode"; +export const PLAN_APPROVE_KIND = "plan_approve"; +export const PLAN_REJECT_KIND = "plan_reject"; + +export async function requestExitPlanModeApproval( + session: SessionState, + toolUseId: string, + inputData: Record, + baseToolCall: ToolCall, +): Promise { + const options: PermissionOption[] = [ + { + option_id: "approve", + name: "Approve", + description: "Approve the plan and continue", + kind: PLAN_APPROVE_KIND, + }, + { + option_id: "reject", + name: "Reject", + description: "Reject the plan", + kind: PLAN_REJECT_KIND, + }, + ]; + + const request: PermissionRequest = { + tool_call: baseToolCall, + options, + }; + + const outcome = await new Promise((resolve) => { + session.pendingPermissions.set(toolUseId, { + onOutcome: resolve, + toolName: EXIT_PLAN_MODE_TOOL_NAME, + inputData, + }); + writeEvent({ event: "permission_request", session_id: session.sessionId, request }); + }); + + if (outcome.outcome !== "selected" || outcome.option_id === "reject") { + setToolCallStatus(session, toolUseId, "failed", "Plan rejected"); + return { behavior: "deny", message: "Plan rejected", toolUseID: toolUseId }; + } + + return { behavior: "allow", updatedInput: inputData, toolUseID: toolUseId }; +} + +export function parseAskUserQuestionPrompts(inputData: Record): AskUserQuestionPrompt[] { + const rawQuestions = Array.isArray(inputData.questions) ? inputData.questions : []; + const prompts: AskUserQuestionPrompt[] = []; + + for (const rawQuestion of rawQuestions) { + const questionRecord = asRecordOrNull(rawQuestion); + if (!questionRecord) { + continue; + } + const question = typeof questionRecord.question === "string" ? questionRecord.question.trim() : ""; + if (!question) { + continue; + } + const headerRaw = typeof questionRecord.header === "string" ? questionRecord.header.trim() : ""; + const header = headerRaw || `Q${prompts.length + 1}`; + const multiSelect = Boolean(questionRecord.multiSelect); + const rawOptions = Array.isArray(questionRecord.options) ? questionRecord.options : []; + const options: AskUserQuestionOption[] = []; + for (const rawOption of rawOptions) { + const optionRecord = asRecordOrNull(rawOption); + if (!optionRecord) { + continue; + } + const label = typeof optionRecord.label === "string" ? optionRecord.label.trim() : ""; + const description = + typeof optionRecord.description === "string" ? optionRecord.description.trim() : ""; + const preview = typeof optionRecord.preview === "string" ? optionRecord.preview.trim() : ""; + if (!label) { + continue; + } + options.push({ + label, + description, + ...(preview.length > 0 ? { preview } : {}), + }); + } + if (options.length < 2) { + continue; + } + prompts.push({ question, header, multiSelect, options }); + } + + return prompts; +} + +function askUserQuestionOptions(prompt: AskUserQuestionPrompt): QuestionOption[] { + return prompt.options.map((option, index) => ({ + option_id: `question_${index}`, + label: option.label, + description: option.description, + ...(option.preview ? { preview: option.preview } : {}), + })); +} + +function askUserQuestionPromptRawInput( + prompt: AskUserQuestionPrompt, + index: number, + total: number, +): Json { + return { + prompt: { + question: prompt.question, + header: prompt.header, + multi_select: prompt.multiSelect, + options: prompt.options.map((option, optionIndex) => ({ + option_id: `question_${optionIndex}`, + label: option.label, + description: option.description, + ...(option.preview ? { preview: option.preview } : {}), + })), + }, + question_index: index, + total_questions: total, + }; +} + +function askUserQuestionPromptToolCall( + base: ToolCall, + prompt: AskUserQuestionPrompt, + index: number, + total: number, +): ToolCall { + return { + ...base, + title: prompt.question, + raw_input: askUserQuestionPromptRawInput(prompt, index, total), + }; +} + +function buildQuestionRequest( + promptToolCall: ToolCall, + prompt: AskUserQuestionPrompt, + index: number, + total: number, +): QuestionRequest { + return { + tool_call: promptToolCall, + prompt: { + question: prompt.question, + header: prompt.header, + multi_select: prompt.multiSelect, + options: askUserQuestionOptions(prompt), + }, + question_index: index, + total_questions: total, + }; +} + +function askUserQuestionTranscript( + answers: Array<{ header: string; question: string; answer: string }>, +): string { + return answers.map((entry) => `${entry.header}: ${entry.answer}\n ${entry.question}`).join("\n"); +} + +function deriveAnnotation( + selectedOptions: QuestionOption[], + annotation?: QuestionAnnotation, +): QuestionAnnotation | undefined { + const preview = annotation?.preview?.trim().length + ? annotation.preview + : selectedOptions + .map((option) => option.preview?.trim() ?? "") + .filter((previewText) => previewText.length > 0) + .join("\n\n"); + const notes = annotation?.notes?.trim().length ? annotation.notes.trim() : undefined; + if (!preview && !notes) { + return undefined; + } + return { + ...(preview ? { preview } : {}), + ...(notes ? { notes } : {}), + }; +} + +export async function requestAskUserQuestionAnswers( + session: SessionState, + toolUseId: string, + inputData: Record, + baseToolCall: ToolCall, +): Promise { + const prompts = parseAskUserQuestionPrompts(inputData); + if (prompts.length === 0) { + return { behavior: "allow", updatedInput: inputData, toolUseID: toolUseId }; + } + + const answers: Record = {}; + const annotations: Record = {}; + const transcript: Array<{ header: string; question: string; answer: string }> = []; + + for (const [index, prompt] of prompts.entries()) { + const promptToolCall = askUserQuestionPromptToolCall(baseToolCall, prompt, index, prompts.length); + const fields: ToolCallUpdateFields = { + title: promptToolCall.title, + status: "in_progress", + raw_input: promptToolCall.raw_input, + }; + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields }, + }); + const tracked = session.toolCalls.get(toolUseId); + if (tracked) { + tracked.title = promptToolCall.title; + tracked.status = "in_progress"; + tracked.raw_input = promptToolCall.raw_input; + } + + const request = buildQuestionRequest(promptToolCall, prompt, index, prompts.length); + const outcome = await new Promise((resolve) => { + session.pendingQuestions.set(toolUseId, { + onOutcome: resolve, + toolName: ASK_USER_QUESTION_TOOL_NAME, + inputData, + }); + writeEvent({ event: "question_request", session_id: session.sessionId, request }); + }); + + if (outcome.outcome !== "answered") { + setToolCallStatus(session, toolUseId, "failed", "Question cancelled"); + return { behavior: "deny", message: "Question cancelled", toolUseID: toolUseId }; + } + + const selectedOptions = request.prompt.options.filter((option) => + outcome.selected_option_ids.includes(option.option_id), + ); + if ( + selectedOptions.length === 0 || + (!prompt.multiSelect && selectedOptions.length !== 1) + ) { + setToolCallStatus(session, toolUseId, "failed", "Question answer was invalid"); + return { behavior: "deny", message: "Question answer was invalid", toolUseID: toolUseId }; + } + + const answer = selectedOptions.map((option) => option.label).join(", "); + answers[prompt.question] = answer; + const annotation = deriveAnnotation(selectedOptions, outcome.annotation); + if (annotation) { + annotations[prompt.question] = annotation; + } + transcript.push({ header: prompt.header, question: prompt.question, answer }); + + const summary = askUserQuestionTranscript(transcript); + const progressFields: ToolCallUpdateFields = { + status: index + 1 >= prompts.length ? "completed" : "in_progress", + raw_output: summary, + content: [{ type: "content", content: { type: "text", text: summary } }], + }; + emitSessionUpdate(session.sessionId, { + type: "tool_call_update", + tool_call_update: { tool_call_id: toolUseId, fields: progressFields }, + }); + if (tracked) { + tracked.status = progressFields.status ?? tracked.status; + tracked.raw_output = summary; + tracked.content = progressFields.content ?? tracked.content; + } + } + + return { + behavior: "allow", + updatedInput: { + ...inputData, + answers, + ...(Object.keys(annotations).length > 0 ? { annotations } : {}), + }, + toolUseID: toolUseId, + }; +} diff --git a/claude-code-rust/agent-sdk/src/types.ts b/claude-code-rust/agent-sdk/src/types.ts new file mode 100644 index 0000000..b35fc36 --- /dev/null +++ b/claude-code-rust/agent-sdk/src/types.ts @@ -0,0 +1,523 @@ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +export interface PromptChunk { + kind: string; + value: Json; +} + +export interface ModeInfo { + id: string; + name: string; + description?: string; +} + +export interface ModeState { + current_mode_id: string; + current_mode_name: string; + available_modes: ModeInfo[]; +} + +export interface AvailableCommand { + name: string; + description: string; + input_hint?: string; +} + +export interface AvailableAgent { + name: string; + description: string; + model?: string; +} + +export type EffortLevel = "low" | "medium" | "high"; + +export interface AvailableModel { + id: string; + display_name: string; + description?: string; + supports_effort: boolean; + supported_effort_levels: EffortLevel[]; + supports_adaptive_thinking?: boolean; + supports_fast_mode?: boolean; + supports_auto_mode?: boolean; +} + +export type FastModeState = "off" | "cooldown" | "on"; +export type RateLimitStatus = "allowed" | "allowed_warning" | "rejected"; + +export interface RateLimitUpdate { + status: RateLimitStatus; + resets_at?: number; + utilization?: number; + rate_limit_type?: string; + overage_status?: RateLimitStatus; + overage_resets_at?: number; + overage_disabled_reason?: string; + is_using_overage?: boolean; + surpassed_threshold?: number; +} + +export type ContentBlock = + | { type: "text"; text: string } + | { type: "image"; mime_type?: string; uri?: string; data?: string }; + +export type ToolCallContent = + | { type: "content"; content: ContentBlock } + | { + type: "diff"; + old_path: string; + new_path: string; + old: string; + new: string; + repository?: string; + } + | { + type: "mcp_resource"; + uri: string; + mime_type?: string; + text?: string; + blob_saved_to?: string; + }; + +export interface ExitPlanModeOutputMetadata { + is_ultraplan?: boolean; +} + +export interface TodoWriteOutputMetadata { + verification_nudge_needed?: boolean; +} + +export interface BashOutputMetadata { + assistant_auto_backgrounded?: boolean; + token_saver_active?: boolean; +} + +export interface ToolOutputMetadata { + bash?: BashOutputMetadata; + exit_plan_mode?: ExitPlanModeOutputMetadata; + todo_write?: TodoWriteOutputMetadata; +} + +export interface ToolLocation { + path: string; + line?: number; +} + +export interface ToolCall { + tool_call_id: string; + title: string; + kind: string; + status: string; + content: ToolCallContent[]; + raw_input?: Json; + raw_output?: string; + output_metadata?: ToolOutputMetadata; + locations: ToolLocation[]; + meta?: Json; +} + +export interface ToolCallUpdateFields { + title?: string; + kind?: string; + status?: string; + content?: ToolCallContent[]; + raw_input?: Json; + raw_output?: string; + output_metadata?: ToolOutputMetadata; + locations?: ToolLocation[]; + meta?: Json; +} + +export interface ToolCallUpdate { + tool_call_id: string; + fields: ToolCallUpdateFields; +} + +export interface PlanEntry { + content: string; + status: string; + active_form: string; +} + +export type SessionUpdate = + | { type: "agent_message_chunk"; content: ContentBlock } + | { type: "user_message_chunk"; content: ContentBlock } + | { type: "agent_thought_chunk"; content: ContentBlock } + | { type: "tool_call"; tool_call: ToolCall } + | { type: "tool_call_update"; tool_call_update: ToolCallUpdate } + | { type: "plan"; entries: PlanEntry[] } + | { type: "available_commands_update"; commands: AvailableCommand[] } + | { type: "available_agents_update"; agents: AvailableAgent[] } + | { type: "mode_state_update"; mode: ModeState } + | { type: "current_mode_update"; current_mode_id: string } + | { type: "config_option_update"; option_id: string; value: Json } + | { type: "fast_mode_update"; fast_mode_state: FastModeState } + | ({ type: "rate_limit_update" } & RateLimitUpdate) + | { type: "session_status_update"; status: "compacting" | "idle" } + | { type: "compaction_boundary"; trigger: "manual" | "auto"; pre_tokens: number }; + +export interface PermissionOption { + option_id: string; + name: string; + description?: string; + kind: string; +} + +export interface PermissionRequest { + tool_call: ToolCall; + options: PermissionOption[]; +} + +export type ElicitationMode = "form" | "url"; + +export type ElicitationAction = "accept" | "decline" | "cancel"; + +export interface QuestionOption { + option_id: string; + label: string; + description?: string; + preview?: string; +} + +export interface QuestionPrompt { + question: string; + header: string; + multi_select: boolean; + options: QuestionOption[]; +} + +export interface QuestionRequest { + tool_call: ToolCall; + prompt: QuestionPrompt; + question_index: number; + total_questions: number; +} + +export interface QuestionAnnotation { + preview?: string; + notes?: string; +} + +export interface ElicitationRequest { + request_id: string; + server_name: string; + message: string; + mode: ElicitationMode; + url?: string; + elicitation_id?: string; + requested_schema?: Record; +} + +export interface ElicitationComplete { + elicitation_id: string; + server_name?: string; +} + +export interface McpAuthRedirect { + server_name: string; + auth_url: string; + requires_user_action: boolean; +} + +export interface McpOperationError { + server_name?: string; + operation: string; + message: string; +} + +export type PermissionOutcome = + | { outcome: "selected"; option_id: string } + | { outcome: "cancelled" }; + +export type QuestionOutcome = + | { + outcome: "answered"; + selected_option_ids: string[]; + annotation?: QuestionAnnotation; + } + | { outcome: "cancelled" }; + +export interface SessionListEntry { + session_id: string; + summary: string; + last_modified_ms: number; + file_size_bytes: number; + cwd?: string; + git_branch?: string; + custom_title?: string; + first_prompt?: string; +} + +export interface AccountInfo { + email?: string; + organization?: string; + subscription_type?: string; + token_source?: string; + api_key_source?: string; +} + +export type McpServerConnectionStatus = + | "connected" + | "failed" + | "needs-auth" + | "pending" + | "disabled"; + +export interface McpServerInfo { + name: string; + version: string; +} + +export interface McpToolAnnotations { + read_only?: boolean; + destructive?: boolean; + open_world?: boolean; +} + +export interface McpTool { + name: string; + description?: string; + annotations?: McpToolAnnotations; +} + +export type McpServerConfig = + | { + type: "stdio"; + command: string; + args?: string[]; + env?: Record; + } + | { + type: "sse"; + url: string; + headers?: Record; + } + | { + type: "http"; + url: string; + headers?: Record; + }; + +export type McpServerStatusConfig = + | McpServerConfig + | { + type: "sdk"; + name: string; + } + | { + type: "claudeai-proxy"; + url: string; + id: string; + }; + +export interface McpServerStatus { + name: string; + status: McpServerConnectionStatus; + server_info?: McpServerInfo; + error?: string; + config?: McpServerStatusConfig; + scope?: string; + tools: McpTool[]; +} + +export interface McpSetServersResult { + added: string[]; + removed: string[]; + errors: Record; +} + +export interface SessionLaunchSettings { + language?: string; + settings?: { [key: string]: Json }; + agent_progress_summaries?: boolean; +} + +export interface BridgeCommandEnvelope { + request_id?: string; + command: string; + [key: string]: unknown; +} + +export type BridgeCommand = + | { + command: "initialize"; + cwd: string; + metadata?: Record; + } + | { + command: "create_session"; + cwd: string; + resume?: string; + launch_settings: SessionLaunchSettings; + metadata?: Record; + } + | { + command: "resume_session"; + session_id: string; + launch_settings: SessionLaunchSettings; + metadata?: Record; + } + | { + command: "prompt"; + session_id: string; + chunks: PromptChunk[]; + } + | { + command: "cancel_turn"; + session_id: string; + } + | { + command: "set_model"; + session_id: string; + model: string; + } + | { + command: "set_mode"; + session_id: string; + mode: string; + } + | { + command: "generate_session_title"; + session_id: string; + description: string; + } + | { + command: "rename_session"; + session_id: string; + title: string; + } + | { + command: "new_session"; + cwd: string; + launch_settings: SessionLaunchSettings; + } + | { + command: "permission_response"; + session_id: string; + tool_call_id: string; + outcome: PermissionOutcome; + } + | { + command: "question_response"; + session_id: string; + tool_call_id: string; + outcome: QuestionOutcome; + } + | { + command: "elicitation_response"; + session_id: string; + elicitation_request_id: string; + action: ElicitationAction; + content?: Record; + } + | { + command: "get_status_snapshot"; + session_id: string; + } + | { + command: "mcp_status"; + session_id: string; + } + | { + command: "mcp_reconnect"; + session_id: string; + server_name: string; + } + | { + command: "mcp_toggle"; + session_id: string; + server_name: string; + enabled: boolean; + } + | { + command: "mcp_set_servers"; + session_id: string; + servers: Record; + } + | { + command: "mcp_authenticate"; + session_id: string; + server_name: string; + } + | { + command: "mcp_clear_auth"; + session_id: string; + server_name: string; + } + | { + command: "mcp_oauth_callback_url"; + session_id: string; + server_name: string; + callback_url: string; + } + | { + command: "shutdown"; + }; + +export interface BridgeEventEnvelope { + request_id?: string; + event: string; + [key: string]: unknown; +} + +export interface InitializeResult { + agent_name: string; + agent_version: string; + auth_methods: Array<{ id: string; name: string; description: string }>; + capabilities: { + prompt_image: boolean; + prompt_embedded_context: boolean; + supports_session_listing: boolean; + supports_resume_session: boolean; + }; +} + +export type TurnErrorKind = "plan_limit" | "auth_required" | "internal" | "other"; + +export type BridgeEvent = + | { + event: "connected"; + session_id: string; + cwd: string; + model_name: string; + available_models: AvailableModel[]; + mode: ModeState | null; + history_updates?: SessionUpdate[]; + } + | { event: "auth_required"; method_name: string; method_description: string } + | { event: "connection_failed"; message: string } + | { event: "session_update"; session_id: string; update: SessionUpdate } + | { event: "permission_request"; session_id: string; request: PermissionRequest } + | { event: "question_request"; session_id: string; request: QuestionRequest } + | { event: "elicitation_request"; session_id: string; request: ElicitationRequest } + | { event: "elicitation_complete"; session_id: string; completion: ElicitationComplete } + | { event: "mcp_auth_redirect"; session_id: string; redirect: McpAuthRedirect } + | { event: "mcp_operation_error"; session_id: string; error: McpOperationError } + | { event: "turn_complete"; session_id: string } + | { + event: "turn_error"; + session_id: string; + message: string; + error_kind?: TurnErrorKind; + sdk_result_subtype?: string; + assistant_error?: string; + } + | { event: "slash_error"; session_id: string; message: string } + | { + event: "session_replaced"; + session_id: string; + cwd: string; + model_name: string; + available_models: AvailableModel[]; + mode: ModeState | null; + history_updates?: SessionUpdate[]; + } + | { event: "initialized"; result: InitializeResult } + | { event: "sessions_listed"; sessions: SessionListEntry[] } + | { event: "status_snapshot"; session_id: string; account: AccountInfo } + | { + event: "mcp_snapshot"; + session_id: string; + servers: McpServerStatus[]; + error?: string; + }; diff --git a/claude-code-rust/agent-sdk/tsconfig.json b/claude-code-rust/agent-sdk/tsconfig.json new file mode 100644 index 0000000..12b371a --- /dev/null +++ b/claude-code-rust/agent-sdk/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ] +} + diff --git a/claude-code-rust/bin/claude-rs.js b/claude-code-rust/bin/claude-rs.js new file mode 100644 index 0000000..03c89d5 --- /dev/null +++ b/claude-code-rust/bin/claude-rs.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +"use strict"; + +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const TARGETS = { + "darwin:arm64": { target: "aarch64-apple-darwin", exe: "claude-rs" }, + "darwin:x64": { target: "x86_64-apple-darwin", exe: "claude-rs" }, + "linux:x64": { target: "x86_64-unknown-linux-gnu", exe: "claude-rs" }, + "win32:x64": { target: "x86_64-pc-windows-msvc", exe: "claude-rs.exe" } +}; + +function resolveInstall() { + const key = `${process.platform}:${process.arch}`; + const info = TARGETS[key]; + if (!info) { + return { error: `Unsupported platform/arch for claude-rs: ${key}` }; + } + + const binaryPath = path.join(__dirname, "..", "vendor", info.target, info.exe); + if (!fs.existsSync(binaryPath)) { + return { + error: + `Missing binary at ${binaryPath}\n` + + "Reinstall with `npm install -g claude-code-rust` to fetch release artifacts." + }; + } + + return { binaryPath }; +} + +const resolved = resolveInstall(); +if (resolved.error) { + console.error(resolved.error); + process.exit(1); +} + +const child = spawn(resolved.binaryPath, process.argv.slice(2), { + stdio: "inherit", + windowsHide: true +}); + +child.on("error", (error) => { + console.error(`Failed to launch claude-rs: ${error.message}`); + process.exit(1); +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/claude-code-rust/clippy.toml b/claude-code-rust/clippy.toml new file mode 100644 index 0000000..55f6a33 --- /dev/null +++ b/claude-code-rust/clippy.toml @@ -0,0 +1,19 @@ +# MSRV for this CLI tool (matches rust-version in Cargo.toml) +msrv = "1.88.0" + +# Binary crate -- no public API to break +avoid-breaking-exported-api = false + +# Tests can use these freely; production code is gated via Cargo.toml lint levels +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-print-in-tests = true +allow-dbg-in-tests = true +allow-indexing-slicing-in-tests = true + +# CLI tools deal with deep path nesting (e.g., ~/.config/claude_rust/...) +absolute-paths-max-segments = 3 + +# Short identifiers common in CLI arg parsing and loops +allowed-idents-below-min-chars = ["i", "j", "k", "x", "y", "z", "n", "s", "c", "op", "tx", "rx", "id", "ip", "os", "ex"] diff --git a/claude-code-rust/package-lock.json b/claude-code-rust/package-lock.json new file mode 100644 index 0000000..8a290fa --- /dev/null +++ b/claude-code-rust/package-lock.json @@ -0,0 +1,360 @@ +{ + "name": "claude-code-rust", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-code-rust", + "version": "0.2.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.2.74" + }, + "bin": { + "claude-rs": "bin/claude-rs.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.74", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.74.tgz", + "integrity": "sha512-S/SFSSbZHPL1HiQxAqCCxU3iHuE5nM+ir0OK1n0bZ+9hlVUH7OOn88AsV9s54E0c1kvH9YF4/foWH8J9kICsBw==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/claude-code-rust/package.json b/claude-code-rust/package.json new file mode 100644 index 0000000..a9845b3 --- /dev/null +++ b/claude-code-rust/package.json @@ -0,0 +1,44 @@ +{ + "name": "claude-code-rust", + "version": "0.2.0", + "description": "Claude Code Rust - native Rust terminal interface for Claude Code", + "keywords": [ + "cli", + "tui", + "claude", + "ai", + "terminal" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/srothgan/claude-code-rust.git" + }, + "bugs": { + "url": "https://github.com/srothgan/claude-code-rust/issues" + }, + "homepage": "https://github.com/srothgan/claude-code-rust#readme", + "bin": { + "claude-rs": "bin/claude-rs.js" + }, + "files": [ + "bin", + "agent-sdk/dist", + "scripts", + "LICENSE", + "README.md" + ], + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.2.74" + }, + "scripts": { + "postinstall": "node ./scripts/postinstall.js", + "prepack": "npm --prefix agent-sdk run build" + }, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/claude-code-rust/rustfmt.toml b/claude-code-rust/rustfmt.toml new file mode 100644 index 0000000..cf491c1 --- /dev/null +++ b/claude-code-rust/rustfmt.toml @@ -0,0 +1,8 @@ +# For explicitness, but is redundant +max_width = 100 +hard_tabs = false +tab_spaces = 4 +newline_style = "Unix" + +# CLI-critical: Keep small structs single-line (essential for Args/Config structs) +use_small_heuristics = "Max" diff --git a/claude-code-rust/scripts/postinstall.js b/claude-code-rust/scripts/postinstall.js new file mode 100644 index 0000000..17b8715 --- /dev/null +++ b/claude-code-rust/scripts/postinstall.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const https = require("node:https"); +const { pipeline } = require("node:stream/promises"); + +const TARGETS = { + "darwin:arm64": { target: "aarch64-apple-darwin", exe: "claude-rs" }, + "darwin:x64": { target: "x86_64-apple-darwin", exe: "claude-rs" }, + "linux:x64": { target: "x86_64-unknown-linux-gnu", exe: "claude-rs" }, + "win32:x64": { target: "x86_64-pc-windows-msvc", exe: "claude-rs.exe" } +}; + +const MAX_REDIRECTS = 5; + +function getTargetInfo() { + return TARGETS[`${process.platform}:${process.arch}`]; +} + +async function downloadFile(url, outPath, redirects = 0) { + if (redirects > MAX_REDIRECTS) { + throw new Error(`Too many redirects while downloading ${url}`); + } + + await new Promise((resolve, reject) => { + const req = https.get( + url, + { headers: { "User-Agent": "claude-code-rust-installer" } }, + (res) => { + const status = res.statusCode ?? 0; + + if (status >= 300 && status < 400 && res.headers.location) { + const nextUrl = new URL(res.headers.location, url).toString(); + res.resume(); + downloadFile(nextUrl, outPath, redirects + 1).then(resolve).catch(reject); + return; + } + + if (status !== 200) { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8").trim(); + reject(new Error(`Download failed (${status}) for ${url}${body ? `: ${body}` : ""}`)); + }); + return; + } + + pipeline(res, fs.createWriteStream(outPath)).then(resolve).catch(reject); + } + ); + + req.on("error", reject); + }); +} + +async function main() { + const info = getTargetInfo(); + if (!info) { + const key = `${process.platform}:${process.arch}`; + throw new Error(`Unsupported platform/arch for claude-code-rust package install: ${key}`); + } + + const pkgJsonPath = path.join(__dirname, "..", "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")); + const version = process.env.npm_package_version || pkg.version; + const tag = `v${version}`; + const repo = "srothgan/claude-code-rust"; + const assetName = `claude-code-rust-${info.target}${info.exe.endsWith(".exe") ? ".exe" : ""}`; + const url = `https://github.com/${repo}/releases/download/${tag}/${assetName}`; + + const installDir = path.join(__dirname, "..", "vendor", info.target); + const binaryPath = path.join(installDir, info.exe); + const tempPath = `${binaryPath}.tmp`; + + fs.mkdirSync(installDir, { recursive: true }); + await downloadFile(url, tempPath); + fs.renameSync(tempPath, binaryPath); + + if (process.platform !== "win32") { + fs.chmodSync(binaryPath, 0o755); + } + + console.log(`Installed claude-code-rust ${version} (${info.target})`); +} + +main().catch((error) => { + console.error(`claude-code-rust postinstall failed: ${error.message}`); + process.exit(1); +}); diff --git a/claude-code-rust/src/agent/bridge.rs b/claude-code-rust/src/agent/bridge.rs new file mode 100644 index 0000000..6268560 --- /dev/null +++ b/claude-code-rust/src/agent/bridge.rs @@ -0,0 +1,91 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::error::AppError; +use anyhow::Context as _; +use std::path::{Path, PathBuf}; +use tokio::process::Command; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BridgeLauncher { + pub runtime_path: PathBuf, + pub script_path: PathBuf, +} + +impl BridgeLauncher { + #[must_use] + pub fn describe(&self) -> String { + format!("{} {}", self.runtime_path.to_string_lossy(), self.script_path.to_string_lossy()) + } + + #[must_use] + pub fn command(&self) -> Command { + let mut cmd = Command::new(&self.runtime_path); + cmd.arg(&self.script_path); + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + cmd + } +} + +pub fn resolve_bridge_launcher(explicit_script: Option<&Path>) -> anyhow::Result { + let runtime = which::which("node") + .map_err(|_| anyhow::Error::new(AppError::NodeNotFound)) + .context("failed to resolve `node` runtime")?; + let script = resolve_bridge_script_path(explicit_script)?; + Ok(BridgeLauncher { runtime_path: runtime, script_path: script }) +} + +fn resolve_bridge_script_path(explicit_script: Option<&Path>) -> anyhow::Result { + if let Some(path) = explicit_script { + return validate_script_path(path); + } + + if let Some(path) = std::env::var_os("CLAUDE_RS_AGENT_BRIDGE") { + return validate_script_path(Path::new(&path)); + } + + let mut candidates = vec![ + PathBuf::from("agent-sdk/dist/bridge.js"), + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("agent-sdk/dist/bridge.js"), + ]; + + if let Ok(current_exe) = std::env::current_exe() { + for ancestor in current_exe.ancestors().skip(1).take(8) { + candidates.push(ancestor.join("agent-sdk/dist/bridge.js")); + } + } + + for candidate in candidates { + if !candidate.as_os_str().is_empty() && candidate.exists() { + return Ok(candidate); + } + } + + Err(anyhow::anyhow!( + "bridge script not found. expected `agent-sdk/dist/bridge.js` or set CLAUDE_RS_AGENT_BRIDGE" + )) +} + +fn validate_script_path(path: &Path) -> anyhow::Result { + if !path.exists() { + return Err(anyhow::anyhow!("bridge script does not exist: {}", path.display())); + } + if !path.is_file() { + return Err(anyhow::anyhow!("bridge script is not a file: {}", path.display())); + } + Ok(path.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::resolve_bridge_launcher; + + #[test] + fn explicit_missing_script_path_fails() { + let result = + resolve_bridge_launcher(Some(std::path::Path::new("agent-sdk/dist/missing.mjs"))); + assert!(result.is_err()); + } +} diff --git a/claude-code-rust/src/agent/client.rs b/claude-code-rust/src/agent/client.rs new file mode 100644 index 0000000..3a49099 --- /dev/null +++ b/claude-code-rust/src/agent/client.rs @@ -0,0 +1,426 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::agent::bridge::BridgeLauncher; +use crate::agent::wire::{BridgeCommand, CommandEnvelope, EventEnvelope, SessionLaunchSettings}; +use crate::error::AppError; +use anyhow::Context as _; +use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader, BufWriter}; +use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout}; +use tokio::sync::mpsc; + +pub struct BridgeClient { + child: Child, + stdin: BufWriter, + stdout: tokio::io::Lines>, +} + +impl BridgeClient { + pub fn spawn(launcher: &BridgeLauncher) -> anyhow::Result { + let mut child = launcher + .command() + .spawn() + .map_err(|_| anyhow::Error::new(AppError::AdapterCrashed)) + .with_context(|| format!("failed to spawn bridge process: {}", launcher.describe()))?; + + let stdin = child.stdin.take().context("bridge stdin not available")?; + let stdout = child.stdout.take().context("bridge stdout not available")?; + let stderr = child.stderr.take().context("bridge stderr not available")?; + Self::spawn_stderr_logger(stderr); + + Ok(Self { child, stdin: BufWriter::new(stdin), stdout: BufReader::new(stdout).lines() }) + } + + fn spawn_stderr_logger(stderr: ChildStderr) { + tokio::task::spawn_local(async move { + let mut lines = BufReader::new(stderr).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => Self::log_bridge_stderr_line(&line), + Ok(None) => break, + Err(err) => { + tracing::error!("failed to read bridge stderr: {err}"); + break; + } + } + } + }); + } + + fn log_bridge_stderr_line(line: &str) { + // The bridge uses a structured "[sdk ]" prefix format. + // Extract an explicit level from it; fall back to debug for ordinary chatter. + let lower = line.to_ascii_lowercase(); + if lower.contains("[sdk error]") || lower.starts_with("error") || lower.contains("panic") { + tracing::error!("bridge stderr: {line}"); + } else if lower.contains("[sdk warn]") || lower.starts_with("warn") { + tracing::warn!("bridge stderr: {line}"); + } else { + tracing::debug!("bridge stderr: {line}"); + } + } + + pub async fn send(&mut self, envelope: CommandEnvelope) -> anyhow::Result<()> { + let line = + serde_json::to_string(&envelope).context("failed to serialize bridge command")?; + self.stdin.write_all(line.as_bytes()).await.context("failed to write bridge command")?; + self.stdin.write_all(b"\n").await.context("failed to write bridge newline")?; + self.stdin.flush().await.context("failed to flush bridge stdin")?; + Ok(()) + } + + pub async fn recv(&mut self) -> anyhow::Result> { + let Some(line) = self.stdout.next_line().await.context("failed to read bridge stdout")? + else { + return Ok(None); + }; + let event: EventEnvelope = + serde_json::from_str(&line).context("failed to decode bridge event json")?; + Ok(Some(event)) + } + + pub async fn shutdown(&mut self) -> anyhow::Result<()> { + self.send(CommandEnvelope { request_id: None, command: BridgeCommand::Shutdown }).await?; + Ok(()) + } + + pub async fn wait(mut self) -> anyhow::Result { + self.child.wait().await.context("failed to wait for bridge process") + } +} + +#[derive(Clone)] +pub struct AgentConnection { + command_tx: mpsc::UnboundedSender, +} + +#[derive(Debug, Clone)] +pub struct PromptResponse { + pub stop_reason: String, +} + +impl AgentConnection { + #[must_use] + pub fn new(command_tx: mpsc::UnboundedSender) -> Self { + Self { command_tx } + } + + pub fn prompt_text(&self, session_id: String, text: String) -> anyhow::Result { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::Prompt { + session_id, + chunks: vec![crate::agent::types::PromptChunk { + kind: "text".to_owned(), + value: serde_json::Value::String(text), + }], + }, + })?; + Ok(PromptResponse { stop_reason: "end_turn".to_owned() }) + } + + pub fn cancel(&self, session_id: String) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::CancelTurn { session_id }, + }) + } + + pub fn set_mode(&self, session_id: String, mode: String) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::SetMode { session_id, mode }, + }) + } + + pub fn generate_session_title( + &self, + session_id: String, + description: String, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::GenerateSessionTitle { session_id, description }, + }) + } + + pub fn rename_session(&self, session_id: String, title: String) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::RenameSession { session_id, title }, + }) + } + + pub fn set_model(&self, session_id: String, model: String) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::SetModel { session_id, model }, + }) + } + + pub fn get_status_snapshot(&self, session_id: String) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::GetStatusSnapshot { session_id }, + }) + } + + pub fn get_mcp_snapshot(&self, session_id: String) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::GetMcpSnapshot { session_id }, + }) + } + + pub fn respond_to_elicitation( + &self, + session_id: String, + elicitation_request_id: String, + action: crate::agent::types::ElicitationAction, + content: Option, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::ElicitationResponse { + session_id, + elicitation_request_id, + action, + content, + }, + }) + } + + pub fn reconnect_mcp_server( + &self, + session_id: String, + server_name: String, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::McpReconnect { session_id, server_name }, + }) + } + + pub fn toggle_mcp_server( + &self, + session_id: String, + server_name: String, + enabled: bool, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::McpToggle { session_id, server_name, enabled }, + }) + } + + pub fn set_mcp_servers( + &self, + session_id: String, + servers: std::collections::BTreeMap, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::McpSetServers { session_id, servers }, + }) + } + + pub fn authenticate_mcp_server( + &self, + session_id: String, + server_name: String, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::McpAuthenticate { session_id, server_name }, + }) + } + + pub fn clear_mcp_auth(&self, session_id: String, server_name: String) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::McpClearAuth { session_id, server_name }, + }) + } + + pub fn submit_mcp_oauth_callback_url( + &self, + session_id: String, + server_name: String, + callback_url: String, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::McpOauthCallbackUrl { session_id, server_name, callback_url }, + }) + } + + pub fn new_session( + &self, + cwd: String, + launch_settings: SessionLaunchSettings, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::NewSession { cwd, launch_settings }, + }) + } + + pub fn resume_session( + &self, + session_id: String, + launch_settings: SessionLaunchSettings, + ) -> anyhow::Result<()> { + self.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::ResumeSession { + session_id, + launch_settings, + metadata: std::collections::BTreeMap::new(), + }, + }) + } + + fn send(&self, envelope: CommandEnvelope) -> anyhow::Result<()> { + self.command_tx.send(envelope).map_err(|_| anyhow::anyhow!("bridge command channel closed")) + } +} + +#[cfg(test)] +mod tests { + use super::AgentConnection; + use crate::agent::types::ElicitationAction; + use crate::agent::wire::BridgeCommand; + use std::collections::BTreeMap; + + #[test] + fn generate_session_title_sends_bridge_command() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let conn = AgentConnection::new(tx); + + conn.generate_session_title("session-1".to_owned(), "Summarize work".to_owned()) + .expect("generate"); + + let envelope = rx.try_recv().expect("command"); + assert_eq!( + envelope.command, + BridgeCommand::GenerateSessionTitle { + session_id: "session-1".to_owned(), + description: "Summarize work".to_owned(), + } + ); + } + + #[test] + fn rename_session_sends_bridge_command() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let conn = AgentConnection::new(tx); + + conn.rename_session("session-1".to_owned(), "Renamed".to_owned()).expect("rename"); + + let envelope = rx.try_recv().expect("command"); + assert_eq!( + envelope.command, + BridgeCommand::RenameSession { + session_id: "session-1".to_owned(), + title: "Renamed".to_owned(), + } + ); + } + + #[test] + fn get_mcp_snapshot_sends_bridge_command() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let conn = AgentConnection::new(tx); + + conn.get_mcp_snapshot("session-1".to_owned()).expect("mcp snapshot"); + + let envelope = rx.try_recv().expect("command"); + assert_eq!( + envelope.command, + BridgeCommand::GetMcpSnapshot { session_id: "session-1".to_owned() } + ); + } + + #[test] + fn reconnect_mcp_server_sends_bridge_command() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let conn = AgentConnection::new(tx); + + conn.reconnect_mcp_server("session-1".to_owned(), "notion".to_owned()) + .expect("mcp reconnect"); + + let envelope = rx.try_recv().expect("command"); + assert_eq!( + envelope.command, + BridgeCommand::McpReconnect { + session_id: "session-1".to_owned(), + server_name: "notion".to_owned(), + } + ); + } + + #[test] + fn toggle_mcp_server_sends_bridge_command() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let conn = AgentConnection::new(tx); + + conn.toggle_mcp_server("session-1".to_owned(), "notion".to_owned(), false) + .expect("mcp toggle"); + + let envelope = rx.try_recv().expect("command"); + assert_eq!( + envelope.command, + BridgeCommand::McpToggle { + session_id: "session-1".to_owned(), + server_name: "notion".to_owned(), + enabled: false, + } + ); + } + + #[test] + fn set_mcp_servers_sends_bridge_command() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let conn = AgentConnection::new(tx); + let servers = BTreeMap::from([( + "notion".to_owned(), + crate::agent::types::McpServerConfig::Http { + url: "https://mcp.notion.com/mcp".to_owned(), + headers: BTreeMap::new(), + }, + )]); + + conn.set_mcp_servers("session-1".to_owned(), servers.clone()).expect("mcp set servers"); + + let envelope = rx.try_recv().expect("command"); + assert_eq!( + envelope.command, + BridgeCommand::McpSetServers { session_id: "session-1".to_owned(), servers } + ); + } + + #[test] + fn respond_to_elicitation_sends_bridge_command() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let conn = AgentConnection::new(tx); + + conn.respond_to_elicitation( + "session-1".to_owned(), + "elicitation-1".to_owned(), + ElicitationAction::Accept, + None, + ) + .expect("elicitation response"); + + let envelope = rx.try_recv().expect("command"); + assert_eq!( + envelope.command, + BridgeCommand::ElicitationResponse { + session_id: "session-1".to_owned(), + elicitation_request_id: "elicitation-1".to_owned(), + action: ElicitationAction::Accept, + content: None, + } + ); + } +} diff --git a/claude-code-rust/src/agent/error_handling.rs b/claude-code-rust/src/agent/error_handling.rs new file mode 100644 index 0000000..74517f6 --- /dev/null +++ b/claude-code-rust/src/agent/error_handling.rs @@ -0,0 +1,262 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurnErrorClass { + PlanLimit, + AuthRequired, + Internal, + Other, +} + +pub fn parse_turn_error_class(tag: &str) -> Option { + match tag { + "plan_limit" => Some(TurnErrorClass::PlanLimit), + "auth_required" => Some(TurnErrorClass::AuthRequired), + "internal" => Some(TurnErrorClass::Internal), + "other" => Some(TurnErrorClass::Other), + _ => None, + } +} + +pub fn classify_turn_error(input: &str) -> TurnErrorClass { + let lower = input.to_ascii_lowercase(); + if looks_like_plan_limit_error_lower(&lower) { + TurnErrorClass::PlanLimit + } else if looks_like_auth_required_error_lower(&lower) { + TurnErrorClass::AuthRequired + } else if looks_like_internal_error_lower(&lower) { + TurnErrorClass::Internal + } else { + TurnErrorClass::Other + } +} + +pub fn looks_like_internal_error(input: &str) -> bool { + looks_like_internal_error_lower(&input.to_ascii_lowercase()) +} + +pub fn summarize_internal_error(input: &str) -> String { + if let Some(summary) = summarize_permission_schema_error(input) { + return truncate_for_log(&summary); + } + if let Some(msg) = extract_xml_tag_value(input, "message") { + return truncate_for_log(msg); + } + if let Some(msg) = extract_json_string_field(input, "message") { + return truncate_for_log(&msg); + } + let fallback = input.lines().find(|line| !line.trim().is_empty()).unwrap_or(input); + truncate_for_log(fallback.trim()) +} + +fn looks_like_plan_limit_error_lower(lower: &str) -> bool { + [ + "rate limit", + "rate-limit", + "max turns", + "max turn", + "max budget", + "quota", + "plan limit", + "plan-limit", + "429", + "too many requests", + "usage limit", + "insufficient quota", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn looks_like_auth_required_error_lower(lower: &str) -> bool { + [ + "/login", + "auth required", + "authentication failed", + "please log in", + "login required", + "not authenticated", + "unauthorized", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn looks_like_internal_error_lower(lower: &str) -> bool { + has_internal_error_keywords(lower) + || looks_like_json_rpc_error_shape(lower) + || looks_like_xml_error_shape(lower) +} + +fn has_internal_error_keywords(lower: &str) -> bool { + [ + "internal error", + "agent sdk", + "claude-agent-sdk", + "adapter", + "bridge", + "json-rpc", + "rpc", + "protocol error", + "transport", + "handshake failed", + "session creation failed", + "connection closed", + "event channel closed", + "tool permission request failed", + "zoderror", + "invalid_union", + "bridge command failed", + "agent stream failed", + "agent initialization failed", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn looks_like_json_rpc_error_shape(lower: &str) -> bool { + (lower.contains("\"jsonrpc\"") && lower.contains("\"error\"")) + || lower.contains("\"code\":-32603") + || lower.contains("\"code\": -32603") +} + +fn looks_like_xml_error_shape(lower: &str) -> bool { + let has_error_node = lower.contains("") || lower.contains(""); + has_error_node && has_detail_node +} + +fn summarize_permission_schema_error(input: &str) -> Option { + let lower = input.to_ascii_lowercase(); + if !lower.contains("tool permission request failed") { + return None; + } + + let detail = if let Some(msg) = extract_json_string_field(input, "message") { + msg + } else { + input.lines().find(|line| !line.trim().is_empty()).unwrap_or(input).trim().to_owned() + }; + + Some(format!("Tool permission request failed: {detail}")) +} + +fn truncate_for_log(input: &str) -> String { + const LIMIT: usize = 240; + let mut out = String::new(); + for (i, ch) in input.chars().enumerate() { + if i >= LIMIT { + out.push_str("..."); + break; + } + out.push(ch); + } + out.replace('\n', "\\n") +} + +fn extract_xml_tag_value<'a>(input: &'a str, tag: &str) -> Option<&'a str> { + let lower = input.to_ascii_lowercase(); + let open = format!("<{tag}>"); + let close = format!(""); + let start = lower.find(&open)? + open.len(); + let end = start + lower[start..].find(&close)?; + let value = input[start..end].trim(); + (!value.is_empty()).then_some(value) +} + +fn extract_json_string_field(input: &str, field: &str) -> Option { + let needle = format!("\"{field}\""); + let start = input.find(&needle)? + needle.len(); + let rest = input[start..].trim_start(); + let colon_idx = rest.find(':')?; + let mut chars = rest[colon_idx + 1..].trim_start().chars(); + if chars.next()? != '"' { + return None; + } + + let mut escaped = false; + let mut out = String::new(); + for ch in chars { + if escaped { + let mapped = match ch { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + '"' => '"', + '\\' => '\\', + _ => ch, + }; + out.push(mapped); + escaped = false; + continue; + } + match ch { + '\\' => escaped = true, + '"' => return Some(out), + _ => out.push(ch), + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{ + TurnErrorClass, classify_turn_error, looks_like_internal_error, parse_turn_error_class, + summarize_internal_error, + }; + + #[test] + fn classifies_plan_limit_errors() { + assert_eq!(classify_turn_error("HTTP 429 Too Many Requests"), TurnErrorClass::PlanLimit); + assert_eq!( + classify_turn_error("turn failed: max budget exceeded"), + TurnErrorClass::PlanLimit + ); + } + + #[test] + fn classifies_auth_required_errors() { + assert_eq!( + classify_turn_error("authentication failed: please log in"), + TurnErrorClass::AuthRequired + ); + } + + #[test] + fn classifies_internal_errors() { + assert_eq!( + classify_turn_error( + r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal rpc fault"}}"# + ), + TurnErrorClass::Internal + ); + assert!(looks_like_internal_error( + "-32603Adapter process crashed" + )); + } + + #[test] + fn classifies_other_errors() { + assert_eq!(classify_turn_error("turn failed: timeout"), TurnErrorClass::Other); + } + + #[test] + fn parses_bridge_turn_error_kind_tags() { + assert_eq!(parse_turn_error_class("plan_limit"), Some(TurnErrorClass::PlanLimit)); + assert_eq!(parse_turn_error_class("auth_required"), Some(TurnErrorClass::AuthRequired)); + assert_eq!(parse_turn_error_class("internal"), Some(TurnErrorClass::Internal)); + assert_eq!(parse_turn_error_class("other"), Some(TurnErrorClass::Other)); + assert_eq!(parse_turn_error_class("unexpected"), None); + } + + #[test] + fn summarize_prefers_permission_schema_error_message() { + let payload = "Tool permission request failed: ZodError: [{\"message\":\"Invalid input: expected record, received undefined\"}]"; + assert_eq!( + summarize_internal_error(payload), + "Tool permission request failed: Invalid input: expected record, received undefined" + ); + } +} diff --git a/claude-code-rust/src/agent/events.rs b/claude-code-rust/src/agent/events.rs new file mode 100644 index 0000000..d9ccc5a --- /dev/null +++ b/claude-code-rust/src/agent/events.rs @@ -0,0 +1,136 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::agent::error_handling::TurnErrorClass; +use crate::agent::model; +use crate::app::plugins::{PluginsCliActionSuccess, PluginsInventorySnapshot}; +use crate::app::{UsageSnapshot, UsageSourceKind}; +use crate::error::AppError; +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +/// Messages sent from the backend bridge path to the App/UI layer. +pub enum ClientEvent { + /// Session update notification (streaming text, tool calls, etc.) + SessionUpdate(model::SessionUpdate), + /// Permission request that needs user input. + PermissionRequest { + request: model::RequestPermissionRequest, + response_tx: tokio::sync::oneshot::Sender, + }, + /// Question request from `AskUserQuestion` that needs structured user input. + QuestionRequest { + request: model::RequestQuestionRequest, + response_tx: tokio::sync::oneshot::Sender, + }, + /// MCP elicitation request that needs auth or other MCP input. + McpElicitationRequest { request: crate::agent::types::ElicitationRequest }, + /// MCP elicitation completed in the SDK. + McpElicitationCompleted { elicitation_id: String, server_name: Option }, + /// MCP auth redirect returned directly by the SDK auth call. + McpAuthRedirect { redirect: crate::agent::types::McpAuthRedirect }, + /// MCP operation failed and should be surfaced in the MCP config UI. + McpOperationError { error: crate::agent::types::McpOperationError }, + /// A prompt turn completed successfully. + TurnComplete, + /// `cancel` notification was accepted by the bridge. + TurnCancelled, + /// A prompt turn failed with an error. + TurnError(String), + /// A prompt turn failed with bridge-provided classification metadata. + TurnErrorClassified { message: String, class: TurnErrorClass }, + /// Background connection completed successfully. + Connected { + session_id: model::SessionId, + cwd: String, + model_name: String, + available_models: Vec, + mode: Option, + history_updates: Vec, + }, + /// Background connection failed. + ConnectionFailed(String), + /// Authentication is required before a session can be created. + AuthRequired { method_name: String, method_description: String }, + /// Slash-command execution failed with a user-facing error. + SlashCommandError(String), + /// Custom slash command replaced the active session. + SessionReplaced { + session_id: model::SessionId, + cwd: String, + model_name: String, + available_models: Vec, + mode: Option, + history_updates: Vec, + }, + /// Recent sessions discovered via SDK session listing. + SessionsListed { sessions: Vec }, + /// Startup update check found a newer published version. + UpdateAvailable { latest_version: String, current_version: String }, + /// Startup Claude Code status check detected degraded/outage conditions. + ServiceStatus { severity: ServiceStatusSeverity, message: String }, + /// /login completed via `claude auth login` -- credentials stored, ready to start a session. + AuthCompleted { conn: Rc }, + /// /logout completed via `claude auth logout`. + LogoutCompleted, + /// Status snapshot received from bridge (account info). + StatusSnapshotReceived { session_id: String, account: crate::agent::types::AccountInfo }, + /// MCP server snapshot received from bridge. + McpSnapshotReceived { + session_id: String, + servers: Vec, + error: Option, + }, + /// Usage refresh task started. + UsageRefreshStarted { epoch: u64 }, + /// Usage refresh completed successfully. + UsageSnapshotReceived { epoch: u64, snapshot: UsageSnapshot }, + /// Usage refresh failed. + UsageRefreshFailed { epoch: u64, message: String, source: UsageSourceKind }, + /// Claude CLI plugin inventory refresh completed. + PluginsInventoryUpdated { + cwd_raw: String, + snapshot: PluginsInventorySnapshot, + claude_path: PathBuf, + }, + /// Claude CLI plugin inventory refresh failed. + PluginsInventoryRefreshFailed { cwd_raw: String, message: String }, + /// Plugin CLI action completed and returned a refreshed inventory snapshot. + PluginsCliActionSucceeded { cwd_raw: String, result: PluginsCliActionSuccess }, + /// Plugin CLI action failed. + PluginsCliActionFailed { cwd_raw: String, message: String }, + /// Fatal app error that should terminate and map to an exit code. + FatalError(AppError), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceStatusSeverity { + Warning, + Error, +} + +/// Shared handle to all spawned terminal processes. +pub type TerminalMap = Rc>>; + +/// Minimal terminal process state used by UI snapshot rendering. +pub struct TerminalProcess { + pub child: Option, + /// Accumulated stdout+stderr - append-only, never cleared. + pub output_buffer: Arc>>, + /// The shell command that was executed. + pub command: String, +} + +/// Kill all spawned terminal child processes. Call on app exit. +pub fn kill_all_terminals(terminals: &TerminalMap) { + let mut map = terminals.borrow_mut(); + for (_, terminal) in map.iter_mut() { + if let Some(child) = terminal.child.as_mut() { + let _ = child.start_kill(); + } + } + map.clear(); +} diff --git a/claude-code-rust/src/agent/mod.rs b/claude-code-rust/src/agent/mod.rs new file mode 100644 index 0000000..8fee1a3 --- /dev/null +++ b/claude-code-rust/src/agent/mod.rs @@ -0,0 +1,10 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +pub mod bridge; +pub mod client; +pub mod error_handling; +pub mod events; +pub mod model; +pub mod types; +pub mod wire; diff --git a/claude-code-rust/src/agent/model.rs b/claude-code-rust/src/agent/model.rs new file mode 100644 index 0000000..7fb8b5e --- /dev/null +++ b/claude-code-rust/src/agent/model.rs @@ -0,0 +1,1050 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SessionId(String); + +impl SessionId { + #[must_use] + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } +} + +impl From for SessionId { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for SessionId { + fn from(value: &str) -> Self { + Self::new(value.to_owned()) + } +} + +impl fmt::Display for SessionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SessionModeId(String); + +impl SessionModeId { + #[must_use] + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } +} + +impl From for SessionModeId { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for SessionModeId { + fn from(value: &str) -> Self { + Self::new(value.to_owned()) + } +} + +impl fmt::Display for SessionModeId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TextContent { + pub text: String, +} + +impl TextContent { + #[must_use] + pub fn new(text: impl Into) -> Self { + Self { text: text.into() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageContent { + pub data: String, + pub mime_type: String, +} + +impl ImageContent { + #[must_use] + pub fn new(data: impl Into, mime_type: impl Into) -> Self { + Self { data: data.into(), mime_type: mime_type.into() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ContentBlock { + Text(TextContent), + Image(ImageContent), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Content { + pub content: ContentBlock, +} + +impl Content { + #[must_use] + pub fn new(content: ContentBlock) -> Self { + Self { content } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentChunk { + pub content: ContentBlock, +} + +impl ContentChunk { + #[must_use] + pub fn new(content: ContentBlock) -> Self { + Self { content } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolKind { + Read, + Edit, + Delete, + Move, + Execute, + Search, + Fetch, + Think, + SwitchMode, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCallStatus { + Pending, + InProgress, + Completed, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolCallLocation { + pub path: PathBuf, + pub line: Option, +} + +impl ToolCallLocation { + #[must_use] + pub fn new(path: impl Into) -> Self { + Self { path: path.into(), line: None } + } + + #[must_use] + pub fn line(mut self, line: u32) -> Self { + self.line = Some(line); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TerminalToolCallContent { + pub terminal_id: String, +} + +impl TerminalToolCallContent { + #[must_use] + pub fn new(terminal_id: impl Into) -> Self { + Self { terminal_id: terminal_id.into() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diff { + pub path: PathBuf, + pub old_text: Option, + pub new_text: String, + pub repository: Option, +} + +impl Diff { + #[must_use] + pub fn new(path: impl Into, new_text: impl Into) -> Self { + Self { path: path.into(), old_text: None, new_text: new_text.into(), repository: None } + } + + #[must_use] + pub fn old_text>(mut self, old_text: Option) -> Self { + self.old_text = old_text.map(Into::into); + self + } + + #[must_use] + pub fn repository(mut self, repository: Option) -> Self { + self.repository = repository.filter(|repository| !repository.trim().is_empty()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpResource { + pub uri: String, + pub mime_type: Option, + pub text: Option, + pub blob_saved_to: Option, +} + +impl McpResource { + #[must_use] + pub fn new(uri: impl Into) -> Self { + Self { uri: uri.into(), mime_type: None, text: None, blob_saved_to: None } + } + + #[must_use] + pub fn mime_type(mut self, mime_type: Option) -> Self { + self.mime_type = mime_type.filter(|mime_type| !mime_type.trim().is_empty()); + self + } + + #[must_use] + pub fn text(mut self, text: Option) -> Self { + self.text = text.filter(|text| !text.trim().is_empty()); + self + } + + #[must_use] + pub fn blob_saved_to(mut self, blob_saved_to: Option) -> Self { + self.blob_saved_to = + blob_saved_to.filter(|path| !path.trim().is_empty()).map(PathBuf::from); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ToolCallContent { + Content(Content), + Diff(Diff), + McpResource(McpResource), + Terminal(TerminalToolCallContent), +} + +impl From<&str> for ToolCallContent { + fn from(value: &str) -> Self { + Self::Content(Content::new(ContentBlock::Text(TextContent::new(value)))) + } +} + +impl From for ToolCallContent { + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow(clippy::struct_field_names)] +pub struct ToolCall { + pub tool_call_id: String, + pub title: String, + pub kind: ToolKind, + pub status: ToolCallStatus, + pub content: Vec, + pub raw_input: Option, + pub raw_output: Option, + pub output_metadata: Option, + pub locations: Vec, + pub meta: Option, +} + +impl ToolCall { + #[must_use] + pub fn new(tool_call_id: impl Into, title: impl Into) -> Self { + Self { + tool_call_id: tool_call_id.into(), + title: title.into(), + kind: ToolKind::Think, + status: ToolCallStatus::Pending, + content: Vec::new(), + raw_input: None, + raw_output: None, + output_metadata: None, + locations: Vec::new(), + meta: None, + } + } + + #[must_use] + pub fn kind(mut self, kind: ToolKind) -> Self { + self.kind = kind; + self + } + + #[must_use] + pub fn status(mut self, status: ToolCallStatus) -> Self { + self.status = status; + self + } + + #[must_use] + pub fn content(mut self, content: Vec) -> Self { + self.content = content; + self + } + + #[must_use] + pub fn raw_input(mut self, raw_input: serde_json::Value) -> Self { + self.raw_input = Some(raw_input); + self + } + + #[must_use] + pub fn raw_output(mut self, raw_output: serde_json::Value) -> Self { + self.raw_output = Some(raw_output); + self + } + + #[must_use] + pub fn output_metadata(mut self, output_metadata: ToolOutputMetadata) -> Self { + self.output_metadata = Some(output_metadata); + self + } + + #[must_use] + pub fn locations(mut self, locations: Vec) -> Self { + self.locations = locations; + self + } + + #[must_use] + pub fn meta(mut self, meta: impl Into) -> Self { + self.meta = Some(meta.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct ToolCallUpdateFields { + pub title: Option, + pub kind: Option, + pub status: Option, + pub content: Option>, + pub raw_input: Option, + pub raw_output: Option, + pub output_metadata: Option, + pub locations: Option>, +} + +impl ToolCallUpdateFields { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + #[must_use] + pub fn kind(mut self, kind: ToolKind) -> Self { + self.kind = Some(kind); + self + } + + #[must_use] + pub fn status(mut self, status: ToolCallStatus) -> Self { + self.status = Some(status); + self + } + + #[must_use] + pub fn content(mut self, content: Vec) -> Self { + self.content = Some(content); + self + } + + #[must_use] + pub fn raw_input(mut self, raw_input: serde_json::Value) -> Self { + self.raw_input = Some(raw_input); + self + } + + #[must_use] + pub fn raw_output(mut self, raw_output: serde_json::Value) -> Self { + self.raw_output = Some(raw_output); + self + } + + #[must_use] + pub fn output_metadata(mut self, output_metadata: ToolOutputMetadata) -> Self { + self.output_metadata = Some(output_metadata); + self + } + + #[must_use] + pub fn locations(mut self, locations: Vec) -> Self { + self.locations = Some(locations); + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow(clippy::struct_field_names)] +pub struct ToolCallUpdate { + pub tool_call_id: String, + pub fields: ToolCallUpdateFields, + pub meta: Option, +} + +impl ToolCallUpdate { + #[must_use] + pub fn new(tool_call_id: impl Into, fields: ToolCallUpdateFields) -> Self { + Self { tool_call_id: tool_call_id.into(), fields, meta: None } + } + + #[must_use] + pub fn meta(mut self, meta: impl Into) -> Self { + self.meta = Some(meta.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ExitPlanModeOutputMetadata { + pub is_ultraplan: Option, +} + +impl ExitPlanModeOutputMetadata { + #[must_use] + pub fn new() -> Self { + Self { is_ultraplan: None } + } + + #[must_use] + pub fn ultraplan(mut self, is_ultraplan: Option) -> Self { + self.is_ultraplan = is_ultraplan; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TodoWriteOutputMetadata { + pub verification_nudge_needed: Option, +} + +impl TodoWriteOutputMetadata { + #[must_use] + pub fn new() -> Self { + Self { verification_nudge_needed: None } + } + + #[must_use] + pub fn verification_nudge_needed(mut self, verification_nudge_needed: Option) -> Self { + self.verification_nudge_needed = verification_nudge_needed; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct BashOutputMetadata { + pub assistant_auto_backgrounded: Option, + pub token_saver_active: Option, +} + +impl BashOutputMetadata { + #[must_use] + pub fn new() -> Self { + Self { assistant_auto_backgrounded: None, token_saver_active: None } + } + + #[must_use] + pub fn assistant_auto_backgrounded( + mut self, + assistant_auto_backgrounded: Option, + ) -> Self { + self.assistant_auto_backgrounded = assistant_auto_backgrounded; + self + } + + #[must_use] + pub fn token_saver_active(mut self, token_saver_active: Option) -> Self { + self.token_saver_active = token_saver_active; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ToolOutputMetadata { + pub bash: Option, + pub exit_plan_mode: Option, + pub todo_write: Option, +} + +impl ToolOutputMetadata { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn bash(mut self, bash: Option) -> Self { + self.bash = bash; + self + } + + #[must_use] + pub fn exit_plan_mode(mut self, exit_plan_mode: Option) -> Self { + self.exit_plan_mode = exit_plan_mode; + self + } + + #[must_use] + pub fn todo_write(mut self, todo_write: Option) -> Self { + self.todo_write = todo_write; + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanEntryPriority { + High, + Medium, + Low, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanEntryStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlanEntry { + pub content: String, + pub priority: PlanEntryPriority, + pub status: PlanEntryStatus, +} + +impl PlanEntry { + #[must_use] + pub fn new( + content: impl Into, + priority: PlanEntryPriority, + status: PlanEntryStatus, + ) -> Self { + Self { content: content.into(), priority, status } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Plan { + pub entries: Vec, +} + +impl Plan { + #[must_use] + pub fn new(entries: Vec) -> Self { + Self { entries } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AvailableCommand { + pub name: String, + pub description: String, + pub input_hint: Option, +} + +impl AvailableCommand { + #[must_use] + pub fn new(name: impl Into, description: impl Into) -> Self { + Self { name: name.into(), description: description.into(), input_hint: None } + } + + #[must_use] + pub fn input_hint(mut self, input_hint: impl Into) -> Self { + self.input_hint = Some(input_hint.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AvailableCommandsUpdate { + pub available_commands: Vec, +} + +impl AvailableCommandsUpdate { + #[must_use] + pub fn new(available_commands: Vec) -> Self { + Self { available_commands } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AvailableAgent { + pub name: String, + pub description: String, + pub model: Option, +} + +impl AvailableAgent { + #[must_use] + pub fn new(name: impl Into, description: impl Into) -> Self { + Self { name: name.into(), description: description.into(), model: None } + } + + #[must_use] + pub fn model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EffortLevel { + Low, + Medium, + High, +} + +impl EffortLevel { + #[must_use] + pub const fn as_stored(self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Low => "Low", + Self::Medium => "Medium", + Self::High => "High", + } + } + + #[must_use] + pub const fn description(self) -> &'static str { + match self { + Self::Low => "Fastest responses", + Self::Medium => "Balanced speed and depth", + Self::High => "Deeper reasoning", + } + } + + #[must_use] + pub fn from_stored(value: &str) -> Option { + match value { + "low" => Some(Self::Low), + "medium" => Some(Self::Medium), + "high" => Some(Self::High), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AvailableModel { + pub id: String, + pub display_name: String, + pub description: Option, + pub supports_effort: bool, + pub supported_effort_levels: Vec, + pub supports_adaptive_thinking: Option, + pub supports_fast_mode: Option, + pub supports_auto_mode: Option, +} + +impl AvailableModel { + #[must_use] + pub fn new(id: impl Into, display_name: impl Into) -> Self { + Self { + id: id.into(), + display_name: display_name.into(), + description: None, + supports_effort: false, + supported_effort_levels: Vec::new(), + supports_adaptive_thinking: None, + supports_fast_mode: None, + supports_auto_mode: None, + } + } + + #[must_use] + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + #[must_use] + pub fn supports_effort(mut self, supports_effort: bool) -> Self { + self.supports_effort = supports_effort; + self + } + + #[must_use] + pub fn supported_effort_levels(mut self, supported_effort_levels: Vec) -> Self { + self.supported_effort_levels = supported_effort_levels; + self + } + + #[must_use] + pub fn supports_adaptive_thinking(mut self, supports_adaptive_thinking: Option) -> Self { + self.supports_adaptive_thinking = supports_adaptive_thinking; + self + } + + #[must_use] + pub fn supports_fast_mode(mut self, supports_fast_mode: Option) -> Self { + self.supports_fast_mode = supports_fast_mode; + self + } + + #[must_use] + pub fn supports_auto_mode(mut self, supports_auto_mode: Option) -> Self { + self.supports_auto_mode = supports_auto_mode; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AvailableAgentsUpdate { + pub available_agents: Vec, +} + +impl AvailableAgentsUpdate { + #[must_use] + pub fn new(available_agents: Vec) -> Self { + Self { available_agents } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CurrentModeUpdate { + pub current_mode_id: SessionModeId, +} + +impl CurrentModeUpdate { + #[must_use] + pub fn new(current_mode_id: impl Into) -> Self { + Self { current_mode_id: current_mode_id.into() } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConfigOptionUpdate { + pub option_id: String, + pub value: serde_json::Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FastModeState { + Off, + Cooldown, + On, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitStatus { + Allowed, + AllowedWarning, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RateLimitUpdate { + pub status: RateLimitStatus, + pub resets_at: Option, + pub utilization: Option, + pub rate_limit_type: Option, + pub overage_status: Option, + pub overage_resets_at: Option, + pub overage_disabled_reason: Option, + pub is_using_overage: Option, + pub surpassed_threshold: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionStatus { + Compacting, + Idle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionTrigger { + Manual, + Auto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompactionBoundary { + pub trigger: CompactionTrigger, + pub pre_tokens: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SessionUpdate { + AgentMessageChunk(ContentChunk), + UserMessageChunk(ContentChunk), + AgentThoughtChunk(ContentChunk), + ToolCall(ToolCall), + ToolCallUpdate(ToolCallUpdate), + Plan(Plan), + AvailableCommandsUpdate(AvailableCommandsUpdate), + AvailableAgentsUpdate(AvailableAgentsUpdate), + ModeStateUpdate(crate::app::ModeState), + CurrentModeUpdate(CurrentModeUpdate), + ConfigOptionUpdate(ConfigOptionUpdate), + FastModeUpdate(FastModeState), + RateLimitUpdate(RateLimitUpdate), + SessionStatusUpdate(SessionStatus), + CompactionBoundary(CompactionBoundary), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionOptionKind { + AllowOnce, + AllowSession, + AllowAlways, + RejectOnce, + RejectAlways, + QuestionChoice, + PlanApprove, + PlanReject, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionOption { + pub option_id: String, + pub name: String, + pub description: Option, + pub kind: PermissionOptionKind, +} + +impl PermissionOption { + #[must_use] + pub fn new( + option_id: impl Into, + name: impl Into, + kind: PermissionOptionKind, + ) -> Self { + Self { option_id: option_id.into(), name: name.into(), description: None, kind } + } + + #[must_use] + pub fn description(mut self, description: Option) -> Self { + self.description = description; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestionOption { + pub option_id: String, + pub label: String, + pub description: Option, + pub preview: Option, +} + +impl QuestionOption { + #[must_use] + pub fn new(option_id: impl Into, label: impl Into) -> Self { + Self { option_id: option_id.into(), label: label.into(), description: None, preview: None } + } + + #[must_use] + pub fn description(mut self, description: Option) -> Self { + self.description = description; + self + } + + #[must_use] + pub fn preview(mut self, preview: Option) -> Self { + self.preview = preview; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestionPrompt { + pub question: String, + pub header: String, + pub multi_select: bool, + pub options: Vec, +} + +impl QuestionPrompt { + #[must_use] + pub fn new( + question: impl Into, + header: impl Into, + multi_select: bool, + options: Vec, + ) -> Self { + Self { question: question.into(), header: header.into(), multi_select, options } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestionAnnotation { + pub preview: Option, + pub notes: Option, +} + +impl QuestionAnnotation { + #[must_use] + pub fn new() -> Self { + Self { preview: None, notes: None } + } + + #[must_use] + pub fn preview(mut self, preview: Option) -> Self { + self.preview = preview; + self + } + + #[must_use] + pub fn notes(mut self, notes: Option) -> Self { + self.notes = notes; + self + } +} + +impl Default for QuestionAnnotation { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SelectedPermissionOutcome { + pub option_id: String, +} + +impl SelectedPermissionOutcome { + #[must_use] + pub fn new(option_id: impl Into) -> Self { + Self { option_id: option_id.into() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum RequestPermissionOutcome { + Selected(SelectedPermissionOutcome), + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnsweredQuestionOutcome { + pub selected_option_ids: Vec, + pub annotation: Option, +} + +impl AnsweredQuestionOutcome { + #[must_use] + pub fn new(selected_option_ids: Vec) -> Self { + Self { selected_option_ids, annotation: None } + } + + #[must_use] + pub fn annotation(mut self, annotation: Option) -> Self { + self.annotation = annotation; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum RequestQuestionOutcome { + Answered(AnsweredQuestionOutcome), + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestPermissionResponse { + pub outcome: RequestPermissionOutcome, +} + +impl RequestPermissionResponse { + #[must_use] + pub fn new(outcome: RequestPermissionOutcome) -> Self { + Self { outcome } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestQuestionResponse { + pub outcome: RequestQuestionOutcome, +} + +impl RequestQuestionResponse { + #[must_use] + pub fn new(outcome: RequestQuestionOutcome) -> Self { + Self { outcome } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RequestPermissionRequest { + pub session_id: SessionId, + pub tool_call: ToolCallUpdate, + pub options: Vec, +} + +impl RequestPermissionRequest { + #[must_use] + pub fn new( + session_id: impl Into, + tool_call: ToolCallUpdate, + options: Vec, + ) -> Self { + Self { session_id: session_id.into(), tool_call, options } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RequestQuestionRequest { + pub session_id: SessionId, + pub tool_call: ToolCallUpdate, + pub prompt: QuestionPrompt, + pub question_index: usize, + pub total_questions: usize, +} + +impl RequestQuestionRequest { + #[must_use] + pub fn new( + session_id: impl Into, + tool_call: ToolCallUpdate, + prompt: QuestionPrompt, + question_index: usize, + total_questions: usize, + ) -> Self { + Self { session_id: session_id.into(), tool_call, prompt, question_index, total_questions } + } +} diff --git a/claude-code-rust/src/agent/types.rs b/claude-code-rust/src/agent/types.rs new file mode 100644 index 0000000..74f44a0 --- /dev/null +++ b/claude-code-rust/src/agent/types.rs @@ -0,0 +1,521 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModeInfo { + pub id: String, + pub name: String, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModeState { + pub current_mode_id: String, + pub current_mode_name: String, + pub available_modes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AvailableCommand { + pub name: String, + pub description: String, + pub input_hint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AvailableAgent { + pub name: String, + pub description: String, + pub model: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EffortLevel { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AvailableModel { + pub id: String, + pub display_name: String, + pub description: Option, + pub supports_effort: bool, + #[serde(default)] + pub supported_effort_levels: Vec, + pub supports_adaptive_thinking: Option, + pub supports_fast_mode: Option, + pub supports_auto_mode: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FastModeState { + Off, + Cooldown, + On, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitStatus { + Allowed, + AllowedWarning, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RateLimitUpdate { + pub status: RateLimitStatus, + pub resets_at: Option, + pub utilization: Option, + pub rate_limit_type: Option, + pub overage_status: Option, + pub overage_resets_at: Option, + pub overage_disabled_reason: Option, + pub is_using_overage: Option, + pub surpassed_threshold: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionStatus { + Compacting, + Idle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionTrigger { + Manual, + Auto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentBlock { + Text { text: String }, + Image { mime_type: Option, uri: Option, data: Option }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow(clippy::struct_field_names)] +pub struct ToolCall { + pub tool_call_id: String, + pub title: String, + pub kind: String, + pub status: String, + pub content: Vec, + pub raw_input: Option, + pub raw_output: Option, + pub output_metadata: Option, + pub locations: Vec, + pub meta: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolCallUpdate { + pub tool_call_id: String, + pub fields: ToolCallUpdateFields, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ToolCallUpdateFields { + pub title: Option, + pub kind: Option, + pub status: Option, + pub content: Option>, + pub raw_input: Option, + pub raw_output: Option, + pub output_metadata: Option, + pub locations: Option>, + pub meta: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolLocation { + pub path: String, + pub line: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ExitPlanModeOutputMetadata { + pub is_ultraplan: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TodoWriteOutputMetadata { + pub verification_nudge_needed: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct BashOutputMetadata { + pub assistant_auto_backgrounded: Option, + pub token_saver_active: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ToolOutputMetadata { + pub bash: Option, + pub exit_plan_mode: Option, + pub todo_write: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolCallContent { + Content { + content: ContentBlock, + }, + Diff { + old_path: String, + new_path: String, + old: String, + new: String, + repository: Option, + }, + McpResource { + uri: String, + mime_type: Option, + text: Option, + blob_saved_to: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlanEntry { + pub content: String, + pub status: String, + pub active_form: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SessionUpdate { + AgentMessageChunk { + content: ContentBlock, + }, + UserMessageChunk { + content: ContentBlock, + }, + AgentThoughtChunk { + content: ContentBlock, + }, + ToolCall { + tool_call: ToolCall, + }, + ToolCallUpdate { + tool_call_update: ToolCallUpdate, + }, + Plan { + entries: Vec, + }, + AvailableCommandsUpdate { + commands: Vec, + }, + AvailableAgentsUpdate { + agents: Vec, + }, + ModeStateUpdate { + mode: ModeState, + }, + CurrentModeUpdate { + current_mode_id: String, + }, + ConfigOptionUpdate { + option_id: String, + value: serde_json::Value, + }, + FastModeUpdate { + fast_mode_state: FastModeState, + }, + RateLimitUpdate { + status: RateLimitStatus, + resets_at: Option, + utilization: Option, + rate_limit_type: Option, + overage_status: Option, + overage_resets_at: Option, + overage_disabled_reason: Option, + is_using_overage: Option, + surpassed_threshold: Option, + }, + SessionStatusUpdate { + status: SessionStatus, + }, + CompactionBoundary { + trigger: CompactionTrigger, + pre_tokens: u64, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionOption { + pub option_id: String, + pub name: String, + pub description: Option, + pub kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionRequest { + pub tool_call: ToolCall, + pub options: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestionOption { + pub option_id: String, + pub label: String, + pub description: Option, + pub preview: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestionPrompt { + pub question: String, + pub header: String, + pub multi_select: bool, + pub options: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestionRequest { + pub tool_call: ToolCall, + pub prompt: QuestionPrompt, + pub question_index: u64, + pub total_questions: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestionAnnotation { + pub preview: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum PermissionOutcome { + Selected { option_id: String }, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum QuestionOutcome { + Answered { selected_option_ids: Vec, annotation: Option }, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ElicitationMode { + Form, + Url, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ElicitationAction { + Accept, + Decline, + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ElicitationRequest { + pub request_id: String, + pub server_name: String, + pub message: String, + pub mode: ElicitationMode, + pub url: Option, + pub elicitation_id: Option, + pub requested_schema: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ElicitationResponse { + pub action: ElicitationAction, + pub content: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpAuthRedirect { + pub server_name: String, + pub auth_url: String, + pub requires_user_action: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpOperationError { + pub server_name: Option, + pub operation: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthMethod { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow(clippy::struct_excessive_bools)] +pub struct AgentCapabilities { + pub prompt_image: bool, + pub prompt_embedded_context: bool, + pub supports_session_listing: bool, + pub supports_resume_session: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InitializeResult { + pub agent_name: String, + pub agent_version: String, + pub auth_methods: Vec, + pub capabilities: AgentCapabilities, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionListEntry { + pub session_id: String, + pub summary: String, + pub last_modified_ms: u64, + pub file_size_bytes: u64, + pub cwd: Option, + pub git_branch: Option, + pub custom_title: Option, + pub first_prompt: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionInit { + pub session_id: String, + pub model_name: String, + pub mode: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PromptChunk { + pub kind: String, + pub value: serde_json::Value, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountInfo { + pub email: Option, + pub organization: Option, + pub subscription_type: Option, + pub token_source: Option, + pub api_key_source: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum McpServerConnectionStatus { + Connected, + Failed, + NeedsAuth, + Pending, + Disabled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpServerInfo { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct McpToolAnnotations { + pub read_only: Option, + pub destructive: Option, + pub open_world: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpTool { + pub name: String, + pub description: Option, + pub annotations: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum McpServerConfig { + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + }, + Sse { + url: String, + #[serde(default)] + headers: BTreeMap, + }, + Http { + url: String, + #[serde(default)] + headers: BTreeMap, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum McpServerStatusConfig { + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + }, + Sse { + url: String, + #[serde(default)] + headers: BTreeMap, + }, + Http { + url: String, + #[serde(default)] + headers: BTreeMap, + }, + Sdk { + name: String, + }, + #[serde(rename = "claudeai-proxy")] + ClaudeaiProxy { + url: String, + id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpServerStatus { + pub name: String, + pub status: McpServerConnectionStatus, + pub server_info: Option, + pub error: Option, + pub config: Option, + pub scope: Option, + #[serde(default)] + pub tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct McpSetServersResult { + #[serde(default)] + pub added: Vec, + #[serde(default)] + pub removed: Vec, + #[serde(default)] + pub errors: BTreeMap, +} diff --git a/claude-code-rust/src/agent/wire.rs b/claude-code-rust/src/agent/wire.rs new file mode 100644 index 0000000..e44afa2 --- /dev/null +++ b/claude-code-rust/src/agent/wire.rs @@ -0,0 +1,290 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::agent::types; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionLaunchSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_progress_summaries: Option, +} + +impl SessionLaunchSettings { + #[must_use] + pub fn is_empty(&self) -> bool { + self.language.is_none() + && self.settings.is_none() + && self.agent_progress_summaries.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandEnvelope { + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(flatten)] + pub command: BridgeCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum BridgeCommand { + Initialize { + cwd: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + metadata: BTreeMap, + }, + CreateSession { + cwd: String, + resume: Option, + #[serde(default, skip_serializing_if = "SessionLaunchSettings::is_empty")] + launch_settings: SessionLaunchSettings, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + metadata: BTreeMap, + }, + ResumeSession { + session_id: String, + #[serde(default, skip_serializing_if = "SessionLaunchSettings::is_empty")] + launch_settings: SessionLaunchSettings, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + metadata: BTreeMap, + }, + Prompt { + session_id: String, + chunks: Vec, + }, + CancelTurn { + session_id: String, + }, + SetModel { + session_id: String, + model: String, + }, + SetMode { + session_id: String, + mode: String, + }, + GenerateSessionTitle { + session_id: String, + description: String, + }, + RenameSession { + session_id: String, + title: String, + }, + NewSession { + cwd: String, + #[serde(default, skip_serializing_if = "SessionLaunchSettings::is_empty")] + launch_settings: SessionLaunchSettings, + }, + PermissionResponse { + session_id: String, + tool_call_id: String, + outcome: types::PermissionOutcome, + }, + QuestionResponse { + session_id: String, + tool_call_id: String, + outcome: types::QuestionOutcome, + }, + ElicitationResponse { + session_id: String, + elicitation_request_id: String, + action: types::ElicitationAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + content: Option, + }, + GetStatusSnapshot { + session_id: String, + }, + GetMcpSnapshot { + session_id: String, + }, + McpReconnect { + session_id: String, + server_name: String, + }, + McpToggle { + session_id: String, + server_name: String, + enabled: bool, + }, + McpSetServers { + session_id: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + servers: BTreeMap, + }, + McpAuthenticate { + session_id: String, + server_name: String, + }, + McpClearAuth { + session_id: String, + server_name: String, + }, + McpOauthCallbackUrl { + session_id: String, + server_name: String, + callback_url: String, + }, + Shutdown, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EventEnvelope { + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(flatten)] + pub event: BridgeEvent, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum BridgeEvent { + Connected { + session_id: String, + cwd: String, + model_name: String, + #[serde(default)] + available_models: Vec, + mode: Option, + history_updates: Option>, + }, + AuthRequired { + method_name: String, + method_description: String, + }, + ConnectionFailed { + message: String, + }, + SessionUpdate { + session_id: String, + update: types::SessionUpdate, + }, + PermissionRequest { + session_id: String, + request: types::PermissionRequest, + }, + QuestionRequest { + session_id: String, + request: types::QuestionRequest, + }, + ElicitationRequest { + session_id: String, + request: types::ElicitationRequest, + }, + ElicitationComplete { + session_id: String, + elicitation_id: String, + server_name: Option, + }, + McpAuthRedirect { + session_id: String, + redirect: types::McpAuthRedirect, + }, + McpOperationError { + session_id: String, + error: types::McpOperationError, + }, + TurnComplete { + session_id: String, + }, + TurnError { + session_id: String, + message: String, + error_kind: Option, + sdk_result_subtype: Option, + assistant_error: Option, + }, + SlashError { + session_id: String, + message: String, + }, + SessionReplaced { + session_id: String, + cwd: String, + model_name: String, + #[serde(default)] + available_models: Vec, + mode: Option, + history_updates: Option>, + }, + Initialized { + result: types::InitializeResult, + }, + SessionsListed { + sessions: Vec, + }, + StatusSnapshot { + session_id: String, + account: types::AccountInfo, + }, + McpSnapshot { + session_id: String, + #[serde(default)] + servers: Vec, + error: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::{ + BridgeCommand, BridgeEvent, CommandEnvelope, EventEnvelope, SessionLaunchSettings, + }; + use crate::agent::types; + + #[test] + fn command_envelope_roundtrip_json() { + let env = CommandEnvelope { + request_id: Some("req-1".to_owned()), + command: BridgeCommand::SetMode { + session_id: "s1".to_owned(), + mode: "plan".to_owned(), + }, + }; + let json = serde_json::to_string(&env).expect("serialize"); + let decoded: CommandEnvelope = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decoded, env); + } + + #[test] + fn event_envelope_roundtrip_json() { + let env = EventEnvelope { + request_id: None, + event: BridgeEvent::SessionUpdate { + session_id: "session-1".to_owned(), + update: types::SessionUpdate::CurrentModeUpdate { + current_mode_id: "default".to_owned(), + }, + }, + }; + let json = serde_json::to_string(&env).expect("serialize"); + let decoded: EventEnvelope = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decoded, env); + } + + #[test] + fn session_launch_settings_serializes_agent_progress_summaries() { + let settings = SessionLaunchSettings { + settings: Some(serde_json::json!({ "model": "haiku" })), + agent_progress_summaries: Some(true), + ..SessionLaunchSettings::default() + }; + + let json = serde_json::to_value(&settings).expect("serialize"); + assert_eq!( + json, + serde_json::json!({ + "settings": { "model": "haiku" }, + "agent_progress_summaries": true + }) + ); + } +} diff --git a/claude-code-rust/src/app/auth.rs b/claude-code-rust/src/app/auth.rs new file mode 100644 index 0000000..f9a6a58 --- /dev/null +++ b/claude-code-rust/src/app/auth.rs @@ -0,0 +1,128 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ClaudeOAuthCredentials { + pub access_token: String, + pub expires_at: Option, +} + +/// Resolved path to `~/.claude/.credentials.json`. +pub(crate) fn credentials_path() -> Option { + dirs::home_dir().map(|h| h.join(".claude").join(".credentials.json")) +} + +pub(crate) fn load_oauth_credentials() -> Option { + let path = credentials_path()?; + load_oauth_credentials_at(&path) +} + +/// Returns `true` when valid OAuth credentials exist on disk. +/// +/// Reads `~/.claude/.credentials.json` and checks that +/// `claudeAiOauth.accessToken` is a non-empty string. +pub fn has_credentials() -> bool { + load_oauth_credentials().is_some() +} + +fn load_oauth_credentials_at(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + let json = serde_json::from_str::(&contents).ok()?; + parse_oauth_credentials(&json) +} + +fn parse_oauth_credentials(json: &serde_json::Value) -> Option { + let oauth = json.get("claudeAiOauth")?; + let access_token = oauth.get("accessToken")?.as_str()?.trim(); + if access_token.is_empty() { + return None; + } + + Some(ClaudeOAuthCredentials { + access_token: access_token.to_owned(), + expires_at: oauth.get("expiresAt").and_then(parse_timestamp_value), + }) +} + +fn parse_timestamp_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Number(number) => number + .as_i64() + .or_else(|| number.as_u64().and_then(|raw| i64::try_from(raw).ok())) + .and_then(system_time_from_epoch), + serde_json::Value::String(raw) => { + raw.trim().parse::().ok().and_then(system_time_from_epoch) + } + _ => None, + } +} + +fn system_time_from_epoch(raw: i64) -> Option { + if raw < 0 { + return None; + } + + let raw = u64::try_from(raw).ok()?; + if raw >= 1_000_000_000_000 { + Some(UNIX_EPOCH + Duration::from_millis(raw)) + } else { + Some(UNIX_EPOCH + Duration::from_secs(raw)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn returns_false_for_nonexistent_file() { + let path = std::path::Path::new("/tmp/claude_test_nonexistent_credentials.json"); + assert!(load_oauth_credentials_at(path).is_none()); + } + + #[test] + fn returns_false_for_empty_json() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, "{{}}").unwrap(); + assert!(load_oauth_credentials_at(tmp.path()).is_none()); + } + + #[test] + fn returns_false_for_empty_access_token() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, r#"{{"claudeAiOauth":{{"accessToken":"","refreshToken":"tok"}}}}"#).unwrap(); + assert!(load_oauth_credentials_at(tmp.path()).is_none()); + } + + #[test] + fn returns_true_for_valid_oauth() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!( + tmp, + r#"{{"claudeAiOauth":{{"accessToken":"sk-ant-oat01-test","refreshToken":"sk-ant-ort01-test","expiresAt":9999999999999}}}}"# + ) + .unwrap(); + assert!(load_oauth_credentials_at(tmp.path()).is_some()); + } + + #[test] + fn parses_expiry_timestamp_in_milliseconds() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, r#"{{"claudeAiOauth":{{"accessToken":"token","expiresAt":1}}}}"#).unwrap(); + + let credentials = load_oauth_credentials_at(tmp.path()).unwrap(); + assert_eq!(credentials.access_token, "token"); + assert_eq!(credentials.expires_at, Some(UNIX_EPOCH + Duration::from_secs(1))); + } + + #[test] + fn returns_false_for_malformed_json() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, "not json at all").unwrap(); + assert!(load_oauth_credentials_at(tmp.path()).is_none()); + } +} diff --git a/claude-code-rust/src/app/cache_policy.rs b/claude-code-rust/src/app/cache_policy.rs new file mode 100644 index 0000000..4ed49b0 --- /dev/null +++ b/claude-code-rust/src/app/cache_policy.rs @@ -0,0 +1,176 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +pub const DEFAULT_CACHE_SPLIT_SOFT_LIMIT_BYTES: usize = 1536; +pub const DEFAULT_CACHE_SPLIT_HARD_LIMIT_BYTES: usize = 4096; +pub const DEFAULT_TOOL_PREVIEW_LIMIT_BYTES: usize = 2048; + +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CacheSplitPolicy { + pub soft_limit_bytes: usize, + pub hard_limit_bytes: usize, + pub preview_limit_bytes: usize, +} + +impl Default for CacheSplitPolicy { + fn default() -> Self { + Self { + soft_limit_bytes: DEFAULT_CACHE_SPLIT_SOFT_LIMIT_BYTES, + hard_limit_bytes: DEFAULT_CACHE_SPLIT_HARD_LIMIT_BYTES, + preview_limit_bytes: DEFAULT_TOOL_PREVIEW_LIMIT_BYTES, + } + } +} + +#[must_use] +pub fn default_cache_split_policy() -> &'static CacheSplitPolicy { + static POLICY: CacheSplitPolicy = CacheSplitPolicy { + soft_limit_bytes: DEFAULT_CACHE_SPLIT_SOFT_LIMIT_BYTES, + hard_limit_bytes: DEFAULT_CACHE_SPLIT_HARD_LIMIT_BYTES, + preview_limit_bytes: DEFAULT_TOOL_PREVIEW_LIMIT_BYTES, + }; + &POLICY +} + +#[must_use] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextSplitKind { + Generic, + ParagraphBoundary, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextSplitDecision { + pub split_at: usize, + pub kind: TextSplitKind, +} + +#[must_use] +pub fn find_text_split(text: &str, policy: CacheSplitPolicy) -> Option { + let bytes = text.as_bytes(); + let mut in_fence = false; + let mut i = 0usize; + + let mut soft_newline = None; + let mut soft_sentence = None; + let mut hard_newline = None; + let mut hard_sentence = None; + let mut post_hard_newline = None; + let mut post_hard_sentence = None; + + while i < bytes.len() { + if (i == 0 || bytes[i - 1] == b'\n') && bytes[i..].starts_with(b"```") { + in_fence = !in_fence; + } + + if !in_fence { + if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' { + let split_at = i + 2; + if split_at < bytes.len() { + return Some(TextSplitDecision { + split_at, + kind: TextSplitKind::ParagraphBoundary, + }); + } + return None; + } + + if bytes[i] == b'\n' { + track_text_split_candidate( + i + 1, + &policy, + &mut soft_newline, + &mut hard_newline, + &mut post_hard_newline, + ); + } + + if is_sentence_boundary(bytes, i) { + track_text_split_candidate( + i + 1, + &policy, + &mut soft_sentence, + &mut hard_sentence, + &mut post_hard_sentence, + ); + } + } + i += 1; + } + + if bytes.len() >= policy.soft_limit_bytes + && let Some(split_at) = pick_text_split_candidate(soft_newline, soft_sentence) + && split_at < bytes.len() + { + return Some(TextSplitDecision { split_at, kind: TextSplitKind::Generic }); + } + + if bytes.len() >= policy.hard_limit_bytes + && let Some(split_at) = + hard_newline.or(post_hard_newline).or(hard_sentence).or(post_hard_sentence) + && split_at < bytes.len() + { + return Some(TextSplitDecision { split_at, kind: TextSplitKind::Generic }); + } + + None +} + +#[must_use] +pub fn find_text_split_index(text: &str, policy: CacheSplitPolicy) -> Option { + find_text_split(text, policy).map(|decision| decision.split_at) +} + +fn track_text_split_candidate( + split_at: usize, + policy: &CacheSplitPolicy, + soft_slot: &mut Option, + hard_slot: &mut Option, + post_hard_slot: &mut Option, +) { + if split_at <= policy.soft_limit_bytes { + *soft_slot = Some(split_at); + } + if split_at <= policy.hard_limit_bytes { + *hard_slot = Some(split_at); + } else if post_hard_slot.is_none() { + *post_hard_slot = Some(split_at); + } +} + +fn pick_text_split_candidate(newline: Option, sentence: Option) -> Option { + newline.or(sentence) +} + +fn is_sentence_boundary(bytes: &[u8], i: usize) -> bool { + matches!(bytes[i], b'.' | b'!' | b'?') + && (i + 1 == bytes.len() || matches!(bytes[i + 1], b' ' | b'\t' | b'\r' | b'\n')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_prefers_double_newline() { + let text = "first\n\nsecond"; + let split_at = find_text_split_index(text, *default_cache_split_policy()); + assert_eq!(split_at, Some("first\n\n".len())); + } + + #[test] + fn split_respects_soft_limit() { + let policy = *default_cache_split_policy(); + let prefix = "a".repeat(policy.soft_limit_bytes - 1); + let text = format!("{prefix}\nsecond line"); + let split_at = find_text_split_index(&text, policy).expect("expected split"); + assert_eq!(&text[..split_at], format!("{prefix}\n")); + } + + #[test] + fn split_ignores_double_newline_inside_fence() { + let text = "```rust\nfirst\n\nsecond\n```"; + assert!(find_text_split_index(text, *default_cache_split_policy()).is_none()); + } +} diff --git a/claude-code-rust/src/app/config/edit.rs b/claude-code-rust/src/app/config/edit.rs new file mode 100644 index 0000000..c4a85f6 --- /dev/null +++ b/claude-code-rust/src/app/config/edit.rs @@ -0,0 +1,899 @@ +use super::resolve::{language_input_validation_message, normalized_language_value}; +use super::{ + AddMarketplaceOverlayState, ConfigOverlayState, DEFAULT_EFFORT_LEVELS, DEFAULT_MODEL_ID, + DEFAULT_MODEL_LABEL, DefaultPermissionMode, LanguageOverlayState, ModelAndEffortOverlayState, + OutputStyle, OutputStyleOverlayState, OverlayFocus, PendingSessionTitleChangeKind, + PendingSessionTitleChangeState, PreferredNotifChannel, ResolvedChoice, ResolvedSettingValue, + SessionRenameOverlayState, SettingFile, SettingId, SettingOptions, SettingSpec, + resolved_setting, setting_display_value, setting_spec, store, +}; +use crate::agent::model::EffortLevel; +use crate::app::App; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde_json::Value; + +pub(super) fn activate_setting(app: &mut App, spec: &SettingSpec) { + match spec.id { + SettingId::AlwaysThinking => { + let next = !store::always_thinking_enabled(&app.config.committed_settings_document) + .unwrap_or(false); + persist_setting_change(app, spec, |document| { + store::set_always_thinking_enabled(document, next); + }); + } + SettingId::ShowTips => { + let next = !store::spinner_tips_enabled(&app.config.committed_local_settings_document) + .unwrap_or(true); + persist_setting_change(app, spec, |document| { + store::set_spinner_tips_enabled(document, next); + }); + } + SettingId::TerminalProgressBar => { + let next = + !store::terminal_progress_bar_enabled(&app.config.committed_preferences_document) + .unwrap_or(true); + persist_setting_change(app, spec, |document| { + store::set_terminal_progress_bar_enabled(document, next); + }); + } + SettingId::ReduceMotion => { + let next = + !store::prefers_reduced_motion(&app.config.committed_local_settings_document) + .unwrap_or(false); + persist_setting_change(app, spec, |document| { + store::set_prefers_reduced_motion(document, next); + }); + } + SettingId::FastMode => { + let next = !store::fast_mode(&app.config.committed_settings_document).unwrap_or(false); + persist_setting_change(app, spec, |document| { + store::set_fast_mode(document, next); + }); + } + SettingId::RespectGitignore => { + let next = !store::respect_gitignore(&app.config.committed_preferences_document) + .unwrap_or(true); + persist_setting_change(app, spec, |document| { + store::set_respect_gitignore(document, next); + }); + } + SettingId::DefaultPermissionMode => { + let current = match super::resolve::resolve_setting_document( + &app.config.committed_settings_document, + SettingId::DefaultPermissionMode, + &[], + ) + .value + { + ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)) => { + DefaultPermissionMode::from_stored(&value).unwrap_or_default() + } + ResolvedSettingValue::Bool(_) + | ResolvedSettingValue::Choice(ResolvedChoice::Automatic) + | ResolvedSettingValue::Text(_) => DefaultPermissionMode::Default, + }; + let next = current.next(); + persist_setting_change(app, spec, |document| { + store::set_default_permission_mode(document, next); + }); + } + SettingId::Language => open_language_overlay(app), + SettingId::Model => open_model_and_effort_overlay(app, OverlayFocus::Model), + SettingId::OutputStyle => open_output_style_overlay(app), + SettingId::ThinkingEffort => { + open_model_and_effort_overlay(app, OverlayFocus::Effort); + } + SettingId::Theme | SettingId::Notifications | SettingId::EditorMode => { + cycle_static_enum(app, spec, 1); + } + } +} + +pub(super) fn step_setting(app: &mut App, spec: &SettingSpec, delta: isize) { + match spec.id { + SettingId::AlwaysThinking + | SettingId::ShowTips + | SettingId::TerminalProgressBar + | SettingId::ReduceMotion + | SettingId::FastMode + | SettingId::RespectGitignore => activate_setting(app, spec), + SettingId::DefaultPermissionMode => { + let current = match super::resolve::resolve_setting_document( + &app.config.committed_settings_document, + SettingId::DefaultPermissionMode, + &[], + ) + .value + { + ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)) => { + DefaultPermissionMode::from_stored(&value).unwrap_or_default() + } + ResolvedSettingValue::Bool(_) + | ResolvedSettingValue::Choice(ResolvedChoice::Automatic) + | ResolvedSettingValue::Text(_) => DefaultPermissionMode::Default, + }; + let next = if delta.is_negative() { current.prev() } else { current.next() }; + persist_setting_change(app, spec, |document| { + store::set_default_permission_mode(document, next); + }); + } + SettingId::Theme | SettingId::Notifications | SettingId::EditorMode => { + cycle_static_enum(app, spec, delta); + } + SettingId::Language + | SettingId::Model + | SettingId::OutputStyle + | SettingId::ThinkingEffort => { + activate_setting(app, spec); + } + } +} + +pub(super) fn handle_overlay_key(app: &mut App, key: KeyEvent) { + if super::mcp_edit::handle_overlay_key(app, key) { + return; + } + match app.config.overlay.clone() { + Some(ConfigOverlayState::ModelAndEffort(_)) => match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => confirm_model_and_effort_overlay(app), + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Tab | KeyCode::Right | KeyCode::Left, KeyModifiers::NONE) + | (KeyCode::BackTab, _) => toggle_model_and_effort_focus(app), + (KeyCode::Up, KeyModifiers::NONE) => move_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_overlay_selection(app, 1), + _ => {} + }, + Some(ConfigOverlayState::OutputStyle(_)) => match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => confirm_output_style_overlay(app), + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Up, KeyModifiers::NONE) => move_output_style_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_output_style_overlay_selection(app, 1), + _ => {} + }, + Some(ConfigOverlayState::InstalledPluginActions(_)) => { + crate::app::plugins::handle_installed_overlay_key(app, key); + } + Some(ConfigOverlayState::PluginInstallActions(_)) => { + crate::app::plugins::handle_plugin_install_overlay_key(app, key); + } + Some(ConfigOverlayState::MarketplaceActions(_)) => { + crate::app::plugins::handle_marketplace_overlay_key(app, key); + } + Some(ConfigOverlayState::AddMarketplace(_)) => { + crate::app::plugins::handle_add_marketplace_overlay_key(app, key); + } + Some( + ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpAuthRedirect(_) + | ConfigOverlayState::McpElicitation(_), + ) + | None => {} + Some(ConfigOverlayState::Language(_)) => handle_language_overlay_key(app, key), + Some(ConfigOverlayState::SessionRename(_)) => handle_session_rename_overlay_key(app, key), + } +} + +pub(super) fn handle_overlay_paste(app: &mut App, text: &str) -> bool { + if super::mcp_edit::handle_overlay_paste(app, text) { + return true; + } + match app.config.overlay { + Some(ConfigOverlayState::Language(_)) => { + insert_text_str(app.config.language_overlay_mut(), text); + true + } + Some(ConfigOverlayState::SessionRename(_)) => { + insert_text_str(app.config.session_rename_overlay_mut(), text); + true + } + Some(ConfigOverlayState::AddMarketplace(_)) => { + insert_text_str(app.config.add_marketplace_overlay_mut(), text); + true + } + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpAuthRedirect(_) + | ConfigOverlayState::McpElicitation(_), + ) + | None => false, + } +} + +pub(crate) fn model_supports_effort(app: &App, model_id: &str) -> bool { + if model_id == DEFAULT_MODEL_ID { + return true; + } + + model_overlay_options(app) + .into_iter() + .find(|option| option.id == model_id) + .is_none_or(|option| option.supports_effort) +} + +pub(crate) fn supported_effort_levels_for_model(app: &App, model_id: &str) -> Vec { + model_overlay_options(app).into_iter().find(|option| option.id == model_id).map_or_else( + Vec::new, + |option| { + if option.supports_effort { option.supported_effort_levels } else { Vec::new() } + }, + ) +} + +#[derive(Debug, Clone)] +pub(crate) struct OverlayModelOption { + pub id: String, + pub display_name: String, + pub description: Option, + pub supports_effort: bool, + pub supported_effort_levels: Vec, + pub supports_adaptive_thinking: Option, + pub supports_fast_mode: Option, + pub supports_auto_mode: Option, +} + +pub(crate) fn model_overlay_options(app: &App) -> Vec { + let mut options = app + .available_models + .iter() + .map(|model| OverlayModelOption { + id: model.id.clone(), + display_name: model.display_name.clone(), + description: model.description.clone(), + supports_effort: model.supports_effort, + supported_effort_levels: if model.supported_effort_levels.is_empty() + && model.supports_effort + { + DEFAULT_EFFORT_LEVELS.to_vec() + } else { + model.supported_effort_levels.clone() + }, + supports_adaptive_thinking: model.supports_adaptive_thinking, + supports_fast_mode: model.supports_fast_mode, + supports_auto_mode: model.supports_auto_mode, + }) + .collect::>(); + if !options.iter().any(|option| option.id == DEFAULT_MODEL_ID) { + options.push(OverlayModelOption { + id: DEFAULT_MODEL_ID.to_owned(), + display_name: DEFAULT_MODEL_LABEL.to_owned(), + description: Some("Uses Claude's default model selection.".to_owned()), + supports_effort: true, + supported_effort_levels: DEFAULT_EFFORT_LEVELS.to_vec(), + supports_adaptive_thinking: None, + supports_fast_mode: None, + supports_auto_mode: None, + }); + } + options.sort_by(|left, right| { + let left_key = left.display_name.to_ascii_lowercase(); + let right_key = right.display_name.to_ascii_lowercase(); + left_key.cmp(&right_key).then_with(|| left.id.cmp(&right.id)) + }); + options +} + +fn persist_setting_change(app: &mut App, spec: &SettingSpec, edit: F) -> bool +where + F: FnOnce(&mut Value), +{ + let Some(path) = app.config.path_for(spec.file).cloned() else { + let message = "Settings paths are not available".to_owned(); + app.config.last_error = Some(message.clone()); + app.config.status_message = None; + return false; + }; + + let previous_respect_gitignore = matches!(spec.id, SettingId::RespectGitignore) + .then(|| app.config.respect_gitignore_effective()); + let mut next_document = app.config.document_for(spec.file).clone(); + edit(&mut next_document); + + match store::save(&path, &next_document) { + Ok(()) => { + *app.config.committed_document_for_mut(spec.file) = next_document; + if previous_respect_gitignore + .is_some_and(|previous| previous != app.config.respect_gitignore_effective()) + { + crate::app::mention::invalidate_session_cache(app); + } + app.reconcile_runtime_from_persisted_settings_change(); + app.config.last_error = None; + app.config.status_message = Some(format!( + "Saved {}: {}", + spec.label, + setting_display_value(app, spec, &resolved_setting(app, spec)) + )); + true + } + Err(err) => { + app.config.last_error = Some(err); + app.config.status_message = None; + false + } + } +} + +fn cycle_static_enum(app: &mut App, spec: &SettingSpec, delta: isize) { + let current = { + let document = app.config.document_for(spec.file); + match store::read_persisted_setting(document, spec) { + Ok(store::PersistedSettingValue::String(value)) => value, + _ => default_static_value(spec.id).to_owned(), + } + }; + + let SettingOptions::Static(options) = spec.options else { + return; + }; + let current_index = + options.iter().position(|option| option.stored == current).unwrap_or_default(); + let next = options[step_index_wrapped(current_index, delta, options.len())].stored; + + persist_setting_change(app, spec, |document| { + if spec.id == SettingId::Notifications { + if let Some(channel) = PreferredNotifChannel::from_stored(next) { + store::set_preferred_notification_channel(document, channel); + } + } else { + store::write_persisted_setting( + document, + spec, + store::PersistedSettingValue::String(next.to_owned()), + ); + } + }); +} + +const fn default_static_value(setting_id: SettingId) -> &'static str { + match setting_id { + SettingId::Theme => "dark", + SettingId::OutputStyle => OutputStyle::Default.as_stored(), + SettingId::ThinkingEffort => "medium", + SettingId::Notifications => "iterm2", + SettingId::EditorMode => "default", + SettingId::AlwaysThinking + | SettingId::ReduceMotion + | SettingId::ShowTips + | SettingId::TerminalProgressBar + | SettingId::FastMode + | SettingId::DefaultPermissionMode + | SettingId::Language + | SettingId::RespectGitignore + | SettingId::Model => "", + } +} + +fn open_model_and_effort_overlay(app: &mut App, focus: OverlayFocus) { + let options = model_overlay_options(app); + let current_model = app + .config + .model_effective() + .filter(|value| options.iter().any(|option| option.id == *value)) + .unwrap_or_else(|| DEFAULT_MODEL_ID.to_owned()); + let current_effort = app.config.thinking_effort_effective(); + let selected_effort = overlay_effort_for_model(app, ¤t_model, current_effort); + app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { + focus, + selected_model: current_model, + selected_effort, + })); + app.config.last_error = None; +} + +fn open_output_style_overlay(app: &mut App) { + app.config.overlay = Some(ConfigOverlayState::OutputStyle(OutputStyleOverlayState { + selected: app.config.output_style_effective(), + })); + app.config.last_error = None; +} + +fn open_language_overlay(app: &mut App) { + let draft = store::language(&app.config.committed_settings_document) + .ok() + .flatten() + .and_then(|value| normalized_language_value(&value)) + .unwrap_or_default(); + app.config.overlay = Some(ConfigOverlayState::Language(text_input_overlay_state( + draft, + LanguageOverlayState::from_text_input, + ))); + app.config.last_error = None; +} + +pub(super) fn open_session_rename_overlay(app: &mut App) { + let Some(session_id) = app.session_id.as_ref() else { + return; + }; + let session_id = session_id.to_string(); + let draft = app + .recent_sessions + .iter() + .find(|session| session.session_id == session_id) + .and_then(|session| session.custom_title.clone()) + .unwrap_or_default(); + app.config.overlay = Some(ConfigOverlayState::SessionRename(text_input_overlay_state( + draft, + SessionRenameOverlayState::from_text_input, + ))); + app.config.last_error = None; +} + +pub(super) fn generate_session_title(app: &mut App) { + let Some(session_id) = app.session_id.as_ref().map(std::string::ToString::to_string) else { + return; + }; + let Some(conn) = app.conn.clone() else { + app.config.last_error = Some("No active bridge connection".to_owned()); + app.config.status_message = None; + return; + }; + let Some(description) = session_title_generation_description(app, &session_id) else { + app.config.last_error = + Some("No session summary is available to generate a title".to_owned()); + app.config.status_message = None; + return; + }; + + match conn.generate_session_title(session_id.clone(), description) { + Ok(()) => { + app.config.pending_session_title_change = Some(PendingSessionTitleChangeState { + session_id, + kind: PendingSessionTitleChangeKind::Generate, + }); + app.config.last_error = None; + app.config.status_message = Some("Generating session title...".to_owned()); + } + Err(err) => { + app.config.last_error = Some(format!("Failed to generate session title: {err}")); + app.config.status_message = None; + } + } +} + +fn toggle_model_and_effort_focus(app: &mut App) { + let Some(overlay) = app.config.model_and_effort_overlay_mut() else { + return; + }; + overlay.focus = match overlay.focus { + OverlayFocus::Model => OverlayFocus::Effort, + OverlayFocus::Effort => OverlayFocus::Model, + }; +} + +fn move_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.model_and_effort_overlay().cloned() else { + return; + }; + match overlay.focus { + OverlayFocus::Model => move_overlay_model_selection(app, &overlay, delta), + OverlayFocus::Effort => move_overlay_effort_selection(app, &overlay, delta), + } +} + +fn move_overlay_model_selection(app: &mut App, overlay: &ModelAndEffortOverlayState, delta: isize) { + let options = model_overlay_options(app); + if options.is_empty() { + return; + } + let current_index = + options.iter().position(|option| option.id == overlay.selected_model).unwrap_or(0); + let next_index = step_index_clamped(current_index, delta, options.len()); + let next_model = &options[next_index]; + let next_effort = overlay_effort_for_model(app, &next_model.id, overlay.selected_effort); + if let Some(state) = app.config.model_and_effort_overlay_mut() { + state.selected_model.clone_from(&next_model.id); + state.selected_effort = next_effort; + } +} + +fn move_overlay_effort_selection( + app: &mut App, + overlay: &ModelAndEffortOverlayState, + delta: isize, +) { + let levels = supported_effort_levels_for_model(app, &overlay.selected_model); + if levels.is_empty() { + return; + } + let current_index = + levels.iter().position(|level| *level == overlay.selected_effort).unwrap_or(0); + let next_index = step_index_clamped(current_index, delta, levels.len()); + if let Some(state) = app.config.model_and_effort_overlay_mut() { + state.selected_effort = levels[next_index]; + } +} + +fn confirm_model_and_effort_overlay(app: &mut App) { + let Some(overlay) = app.config.model_and_effort_overlay().cloned() else { + return; + }; + if persist_model_and_effort_change(app, &overlay.selected_model, overlay.selected_effort) { + app.config.overlay = None; + } +} + +fn move_output_style_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.output_style_overlay().copied() else { + return; + }; + let current_index = + OutputStyle::ALL.iter().position(|style| *style == overlay.selected).unwrap_or_default(); + let next_index = step_index_clamped(current_index, delta, OutputStyle::ALL.len()); + if let Some(state) = app.config.output_style_overlay_mut() { + state.selected = OutputStyle::ALL[next_index]; + } +} + +fn confirm_output_style_overlay(app: &mut App) { + let Some(overlay) = app.config.output_style_overlay().copied() else { + return; + }; + let spec = setting_spec(SettingId::OutputStyle); + if persist_setting_change(app, spec, |document| { + store::set_output_style(document, overlay.selected); + }) { + app.config.overlay = None; + } +} + +fn handle_language_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => confirm_language_overlay(app), + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Left, KeyModifiers::NONE) => { + move_text_cursor_left(app.config.language_overlay_mut()); + } + (KeyCode::Right, KeyModifiers::NONE) => { + move_text_cursor_right(app.config.language_overlay_mut()); + } + (KeyCode::Home, KeyModifiers::NONE) => { + set_text_cursor(app.config.language_overlay_mut(), 0); + } + (KeyCode::End, KeyModifiers::NONE) => { + move_text_cursor_to_end(app.config.language_overlay_mut()); + } + (KeyCode::Backspace, KeyModifiers::NONE) => { + delete_text_before_cursor(app.config.language_overlay_mut()); + } + (KeyCode::Delete, KeyModifiers::NONE) => { + delete_text_at_cursor(app.config.language_overlay_mut()); + } + (KeyCode::Char(ch), modifiers) if accepts_text_input(modifiers) => { + insert_text_char(app.config.language_overlay_mut(), ch); + } + _ => {} + } +} + +pub(super) fn accepts_text_input(modifiers: KeyModifiers) -> bool { + modifiers.is_empty() || modifiers == KeyModifiers::SHIFT +} + +fn confirm_language_overlay(app: &mut App) { + let Some(overlay) = app.config.language_overlay().cloned() else { + return; + }; + let normalized = normalized_language_value(&overlay.draft); + if normalized.as_deref().is_some_and(|value| language_input_validation_message(value).is_some()) + { + return; + } + + let spec = setting_spec(SettingId::Language); + if persist_setting_change(app, spec, |document| { + store::set_language(document, normalized.as_deref()); + }) { + app.config.overlay = None; + } +} + +fn handle_session_rename_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => confirm_session_rename_overlay(app), + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Left, KeyModifiers::NONE) => { + move_text_cursor_left(app.config.session_rename_overlay_mut()); + } + (KeyCode::Right, KeyModifiers::NONE) => { + move_text_cursor_right(app.config.session_rename_overlay_mut()); + } + (KeyCode::Home, KeyModifiers::NONE) => { + set_text_cursor(app.config.session_rename_overlay_mut(), 0); + } + (KeyCode::End, KeyModifiers::NONE) => { + move_text_cursor_to_end(app.config.session_rename_overlay_mut()); + } + (KeyCode::Backspace, KeyModifiers::NONE) => { + delete_text_before_cursor(app.config.session_rename_overlay_mut()); + } + (KeyCode::Delete, KeyModifiers::NONE) => { + delete_text_at_cursor(app.config.session_rename_overlay_mut()); + } + (KeyCode::Char(ch), modifiers) if accepts_text_input(modifiers) => { + insert_text_char(app.config.session_rename_overlay_mut(), ch); + } + _ => {} + } +} + +fn confirm_session_rename_overlay(app: &mut App) { + let Some(session_id) = app.session_id.as_ref().map(std::string::ToString::to_string) else { + app.config.overlay = None; + return; + }; + let Some(conn) = app.conn.clone() else { + app.config.last_error = Some("No active bridge connection".to_owned()); + app.config.status_message = None; + return; + }; + let Some(overlay) = app.config.session_rename_overlay().cloned() else { + return; + }; + + let trimmed = overlay.draft.trim().to_owned(); + let requested_title = (!trimmed.is_empty()).then_some(trimmed.clone()); + match conn.rename_session(session_id.clone(), trimmed) { + Ok(()) => { + app.config.pending_session_title_change = Some(PendingSessionTitleChangeState { + session_id, + kind: PendingSessionTitleChangeKind::Rename { + requested_title: requested_title.clone(), + }, + }); + app.config.overlay = None; + app.config.last_error = None; + app.config.status_message = Some(if requested_title.is_some() { + "Renaming session...".to_owned() + } else { + "Clearing session name...".to_owned() + }); + } + Err(err) => { + app.config.last_error = Some(format!("Failed to rename session: {err}")); + app.config.status_message = None; + } + } +} + +fn persist_model_and_effort_change(app: &mut App, model: &str, effort: EffortLevel) -> bool { + let Some(path) = app.config.path_for(SettingFile::Settings).cloned() else { + app.config.last_error = Some("Settings paths are not available".to_owned()); + app.config.status_message = None; + return false; + }; + let mut next_document = app.config.committed_settings_document.clone(); + store::set_model(&mut next_document, Some(model)); + if model_supports_effort(app, model) { + store::set_thinking_effort_level(&mut next_document, effort); + } + match store::save(&path, &next_document) { + Ok(()) => { + app.config.committed_settings_document = next_document; + app.reconcile_runtime_from_persisted_settings_change(); + app.config.last_error = None; + app.config.status_message = None; + true + } + Err(err) => { + app.config.last_error = Some(err); + app.config.status_message = None; + false + } + } +} + +fn overlay_effort_for_model(app: &App, model_id: &str, current: EffortLevel) -> EffortLevel { + let supported = supported_effort_levels_for_model(app, model_id); + if supported.is_empty() || supported.contains(¤t) { + return current; + } + supported.iter().copied().find(|level| *level == EffortLevel::Medium).unwrap_or(supported[0]) +} + +pub(super) fn step_index_clamped(current: usize, delta: isize, len: usize) -> usize { + if len == 0 { + return 0; + } + if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()).min(len.saturating_sub(1)) + } else { + (current + delta.cast_unsigned()).min(len.saturating_sub(1)) + } +} + +fn step_index_wrapped(current: usize, delta: isize, len: usize) -> usize { + if len == 0 { + return 0; + } + if delta.is_negative() { + (current + len - (delta.unsigned_abs() % len)) % len + } else { + (current + delta.cast_unsigned()) % len + } +} + +fn char_to_byte_index(text: &str, char_index: usize) -> usize { + text.char_indices().nth(char_index).map_or(text.len(), |(idx, _)| idx) +} + +fn session_title_generation_description(app: &App, session_id: &str) -> Option { + let session = app.recent_sessions.iter().find(|session| session.session_id == session_id)?; + [ + session.custom_title.as_deref(), + Some(session.summary.as_str()), + session.first_prompt.as_deref(), + ] + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()) + .map(str::to_owned) +} + +pub(super) fn text_input_overlay_state( + draft: String, + build: impl FnOnce(String, usize) -> T, +) -> T { + let cursor = draft.chars().count(); + build(draft, cursor) +} + +pub(super) fn move_text_cursor_left(overlay: Option<&mut T>) { + let Some(overlay) = overlay else { + return; + }; + *overlay.cursor_mut() = overlay.cursor().saturating_sub(1); +} + +pub(super) fn move_text_cursor_right(overlay: Option<&mut T>) { + let Some(overlay) = overlay else { + return; + }; + let next = overlay.cursor().saturating_add(1).min(overlay.draft().chars().count()); + *overlay.cursor_mut() = next; +} + +pub(super) fn move_text_cursor_to_end(overlay: Option<&mut T>) { + let Some(overlay) = overlay else { + return; + }; + *overlay.cursor_mut() = overlay.draft().chars().count(); +} + +pub(super) fn set_text_cursor(overlay: Option<&mut T>, cursor: usize) { + let Some(overlay) = overlay else { + return; + }; + *overlay.cursor_mut() = cursor.min(overlay.draft().chars().count()); +} + +pub(super) fn insert_text_char(overlay: Option<&mut T>, ch: char) { + let Some(overlay) = overlay else { + return; + }; + let byte_index = char_to_byte_index(overlay.draft(), overlay.cursor()); + overlay.draft_mut().insert(byte_index, ch); + *overlay.cursor_mut() += 1; +} + +pub(super) fn insert_text_str(overlay: Option<&mut T>, text: &str) { + let Some(overlay) = overlay else { + return; + }; + let byte_index = char_to_byte_index(overlay.draft(), overlay.cursor()); + let normalized = text.replace("\r\n", "\n").replace('\r', "\n").replace('\n', " "); + overlay.draft_mut().insert_str(byte_index, &normalized); + *overlay.cursor_mut() += normalized.chars().count(); +} + +pub(super) fn delete_text_before_cursor(overlay: Option<&mut T>) { + let Some(overlay) = overlay else { + return; + }; + if overlay.cursor() == 0 { + return; + } + let end = char_to_byte_index(overlay.draft(), overlay.cursor()); + let start = char_to_byte_index(overlay.draft(), overlay.cursor() - 1); + overlay.draft_mut().replace_range(start..end, ""); + *overlay.cursor_mut() -= 1; +} + +pub(super) fn delete_text_at_cursor(overlay: Option<&mut T>) { + let Some(overlay) = overlay else { + return; + }; + let char_count = overlay.draft().chars().count(); + if overlay.cursor() >= char_count { + return; + } + let start = char_to_byte_index(overlay.draft(), overlay.cursor()); + let end = char_to_byte_index(overlay.draft(), overlay.cursor() + 1); + overlay.draft_mut().replace_range(start..end, ""); +} + +pub(super) trait TextInputOverlay { + fn draft(&self) -> &str; + fn draft_mut(&mut self) -> &mut String; + fn cursor(&self) -> usize; + fn cursor_mut(&mut self) -> &mut usize; +} + +impl TextInputOverlay for LanguageOverlayState { + fn draft(&self) -> &str { + &self.draft + } + + fn draft_mut(&mut self) -> &mut String { + &mut self.draft + } + + fn cursor(&self) -> usize { + self.cursor + } + + fn cursor_mut(&mut self) -> &mut usize { + &mut self.cursor + } +} + +impl LanguageOverlayState { + fn from_text_input(draft: String, cursor: usize) -> Self { + Self { draft, cursor } + } +} + +impl TextInputOverlay for SessionRenameOverlayState { + fn draft(&self) -> &str { + &self.draft + } + + fn draft_mut(&mut self) -> &mut String { + &mut self.draft + } + + fn cursor(&self) -> usize { + self.cursor + } + + fn cursor_mut(&mut self) -> &mut usize { + &mut self.cursor + } +} + +impl SessionRenameOverlayState { + fn from_text_input(draft: String, cursor: usize) -> Self { + Self { draft, cursor } + } +} + +impl TextInputOverlay for AddMarketplaceOverlayState { + fn draft(&self) -> &str { + &self.draft + } + + fn draft_mut(&mut self) -> &mut String { + &mut self.draft + } + + fn cursor(&self) -> usize { + self.cursor + } + + fn cursor_mut(&mut self) -> &mut usize { + &mut self.cursor + } +} + +impl AddMarketplaceOverlayState { + pub(crate) fn from_text_input(draft: String, cursor: usize) -> Self { + Self { draft, cursor } + } +} diff --git a/claude-code-rust/src/app/config/mcp.rs b/claude-code-rust/src/app/config/mcp.rs new file mode 100644 index 0000000..d843f9a --- /dev/null +++ b/claude-code-rust/src/app/config/mcp.rs @@ -0,0 +1,477 @@ +use super::{ConfigOverlayState, ConfigState, ConfigTab}; +use crate::app::App; +use crate::app::view::{self, ActiveView}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum McpServerActionKind { + RefreshSnapshot, + Authenticate, + ClearAuth, + Reconnect, + Enable, + Disable, +} + +impl McpServerActionKind { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::RefreshSnapshot => "Refresh", + Self::Authenticate => "Authenticate", + Self::ClearAuth => "Clear auth", + Self::Reconnect => "Reconnect server", + Self::Enable => "Enable server", + Self::Disable => "Disable server", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpDetailsOverlayState { + pub server_name: String, + pub selected_index: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpCallbackUrlOverlayState { + pub server_name: String, + pub draft: String, + pub cursor: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct McpElicitationOverlayState { + pub request: crate::agent::types::ElicitationRequest, + pub selected_index: usize, + pub browser_opened: bool, + pub browser_open_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpAuthRedirectOverlayState { + pub redirect: crate::agent::types::McpAuthRedirect, + pub selected_index: usize, + pub browser_opened: bool, + pub browser_open_error: Option, +} + +impl ConfigState { + #[must_use] + pub fn mcp_details_overlay(&self) -> Option<&McpDetailsOverlayState> { + if let Some(ConfigOverlayState::McpDetails(overlay)) = &self.overlay { + Some(overlay) + } else { + None + } + } + + pub fn mcp_details_overlay_mut(&mut self) -> Option<&mut McpDetailsOverlayState> { + if let Some(ConfigOverlayState::McpDetails(overlay)) = &mut self.overlay { + Some(overlay) + } else { + None + } + } + + #[must_use] + pub fn mcp_callback_url_overlay(&self) -> Option<&McpCallbackUrlOverlayState> { + if let Some(ConfigOverlayState::McpCallbackUrl(overlay)) = &self.overlay { + Some(overlay) + } else { + None + } + } + + pub fn mcp_callback_url_overlay_mut(&mut self) -> Option<&mut McpCallbackUrlOverlayState> { + if let Some(ConfigOverlayState::McpCallbackUrl(overlay)) = &mut self.overlay { + Some(overlay) + } else { + None + } + } + + #[must_use] + pub fn mcp_elicitation_overlay(&self) -> Option<&McpElicitationOverlayState> { + if let Some(ConfigOverlayState::McpElicitation(overlay)) = &self.overlay { + Some(overlay) + } else { + None + } + } + + pub fn mcp_elicitation_overlay_mut(&mut self) -> Option<&mut McpElicitationOverlayState> { + if let Some(ConfigOverlayState::McpElicitation(overlay)) = &mut self.overlay { + Some(overlay) + } else { + None + } + } + + #[must_use] + pub fn mcp_auth_redirect_overlay(&self) -> Option<&McpAuthRedirectOverlayState> { + if let Some(ConfigOverlayState::McpAuthRedirect(overlay)) = &self.overlay { + Some(overlay) + } else { + None + } + } + + pub fn mcp_auth_redirect_overlay_mut(&mut self) -> Option<&mut McpAuthRedirectOverlayState> { + if let Some(ConfigOverlayState::McpAuthRedirect(overlay)) = &mut self.overlay { + Some(overlay) + } else { + None + } + } +} + +pub(super) fn handle_mcp_key(app: &mut App, key: KeyEvent) -> bool { + if app.config.active_tab != ConfigTab::Mcp { + return false; + } + + match (key.code, key.modifiers) { + (KeyCode::Char(ch), modifiers) + if matches!(ch, 'r' | 'R') + && (modifiers.is_empty() || modifiers == KeyModifiers::SHIFT) => + { + refresh_mcp_snapshot(app); + true + } + (KeyCode::Enter, KeyModifiers::NONE) => { + open_selected_mcp_server_details(app); + true + } + (KeyCode::Up, KeyModifiers::NONE) => { + app.config.mcp_selected_server_index = + app.config.mcp_selected_server_index.saturating_sub(1); + true + } + (KeyCode::Down, KeyModifiers::NONE) => { + let last_index = app.mcp.servers.len().saturating_sub(1); + app.config.mcp_selected_server_index = + (app.config.mcp_selected_server_index + 1).min(last_index); + true + } + _ => false, + } +} + +pub(crate) fn refresh_mcp_snapshot_if_needed(app: &mut App) { + if app.config.active_tab != ConfigTab::Mcp { + tracing::debug!("skipping MCP refresh request: active_tab={:?}", app.config.active_tab); + return; + } + refresh_mcp_snapshot(app); +} + +pub(crate) fn refresh_mcp_snapshot(app: &mut App) { + app.mcp.servers.clear(); + app.mcp.last_error = None; + request_mcp_snapshot(app); +} + +pub(crate) fn request_mcp_snapshot(app: &mut App) { + let Some(conn) = app.conn.as_ref() else { + app.mcp.in_flight = false; + return; + }; + let Some(ref sid) = app.session_id else { + app.mcp.in_flight = false; + return; + }; + tracing::debug!("requesting MCP snapshot: session_id={sid}"); + app.mcp.in_flight = true; + app.mcp.last_error = None; + if let Err(err) = conn.get_mcp_snapshot(sid.to_string()) { + app.mcp.in_flight = false; + app.mcp.last_error = Some(err.to_string()); + tracing::warn!("failed to request MCP snapshot: {err}"); + } +} + +pub(crate) fn reconnect_mcp_server(app: &mut App, server_name: &str) { + let Some(conn) = app.conn.as_ref() else { + return; + }; + let Some(ref sid) = app.session_id else { + return; + }; + if conn.reconnect_mcp_server(sid.to_string(), server_name.to_owned()).is_ok() { + refresh_mcp_snapshot(app); + } +} + +pub(crate) fn set_mcp_server_enabled(app: &mut App, server_name: &str, enabled: bool) { + let Some(conn) = app.conn.as_ref() else { + return; + }; + let Some(ref sid) = app.session_id else { + return; + }; + if conn.toggle_mcp_server(sid.to_string(), server_name.to_owned(), enabled).is_ok() { + refresh_mcp_snapshot(app); + } +} + +pub(crate) fn authenticate_mcp_server(app: &mut App, server_name: &str) { + let Some(conn) = app.conn.as_ref() else { + return; + }; + let Some(ref sid) = app.session_id else { + return; + }; + if conn.authenticate_mcp_server(sid.to_string(), server_name.to_owned()).is_ok() { + app.config.status_message = Some(format!("Starting MCP auth for {server_name}...")); + app.config.last_error = None; + refresh_mcp_snapshot(app); + } +} + +pub(crate) fn clear_mcp_server_auth(app: &mut App, server_name: &str) { + let Some(conn) = app.conn.as_ref() else { + return; + }; + let Some(ref sid) = app.session_id else { + return; + }; + if conn.clear_mcp_auth(sid.to_string(), server_name.to_owned()).is_ok() { + refresh_mcp_snapshot(app); + } +} + +pub(crate) fn submit_mcp_oauth_callback_url( + app: &mut App, + server_name: &str, + callback_url: String, +) { + let Some(conn) = app.conn.as_ref() else { + return; + }; + let Some(ref sid) = app.session_id else { + return; + }; + if conn + .submit_mcp_oauth_callback_url(sid.to_string(), server_name.to_owned(), callback_url) + .is_ok() + { + refresh_mcp_snapshot(app); + } +} + +pub(crate) fn send_mcp_elicitation_response( + app: &mut App, + request_id: &str, + action: crate::agent::types::ElicitationAction, + content: Option, +) { + let Some(conn) = app.conn.as_ref() else { + return; + }; + let Some(ref sid) = app.session_id else { + return; + }; + if conn.respond_to_elicitation(sid.to_string(), request_id.to_owned(), action, content).is_ok() + { + app.mcp.pending_elicitation = None; + refresh_mcp_snapshot(app); + } +} + +fn open_selected_mcp_server_details(app: &mut App) { + let Some(server_name) = + app.mcp.servers.get(app.config.mcp_selected_server_index).map(|server| server.name.clone()) + else { + return; + }; + open_mcp_server_details(app, server_name, None); +} + +pub(crate) fn open_mcp_server_details( + app: &mut App, + server_name: String, + preferred_action: Option, +) { + let selected_index = + app.mcp.servers.iter().find(|server| server.name == server_name).map_or(0, |server| { + preferred_action + .and_then(|action| { + available_mcp_actions(server).iter().position(|candidate| *candidate == action) + }) + .unwrap_or(0) + }); + app.config.overlay = Some(ConfigOverlayState::McpDetails(McpDetailsOverlayState { + server_name, + selected_index, + })); + app.config.last_error = None; +} + +#[must_use] +pub(crate) fn available_mcp_actions( + server: &crate::agent::types::McpServerStatus, +) -> Vec { + let mut actions = vec![McpServerActionKind::RefreshSnapshot]; + if matches!(server.status, crate::agent::types::McpServerConnectionStatus::Disabled) { + actions.push(McpServerActionKind::Enable); + } else { + if matches!( + server.status, + crate::agent::types::McpServerConnectionStatus::NeedsAuth + | crate::agent::types::McpServerConnectionStatus::Failed + | crate::agent::types::McpServerConnectionStatus::Pending + ) { + actions.push(McpServerActionKind::Authenticate); + } + actions.push(McpServerActionKind::ClearAuth); + actions.push(McpServerActionKind::Reconnect); + actions.push(McpServerActionKind::Disable); + } + actions +} + +#[must_use] +pub(crate) fn is_mcp_action_available( + server: &crate::agent::types::McpServerStatus, + action: McpServerActionKind, +) -> bool { + !matches!( + (action, server.config.as_ref()), + ( + McpServerActionKind::Authenticate, + Some(crate::agent::types::McpServerStatusConfig::ClaudeaiProxy { .. }) + ) + ) +} + +pub(crate) fn present_mcp_elicitation_request( + app: &mut App, + request: crate::agent::types::ElicitationRequest, +) { + app.mcp.pending_elicitation = Some(request.clone()); + view::set_active_view(app, ActiveView::Config); + app.config.active_tab = ConfigTab::Mcp; + refresh_mcp_snapshot(app); + let (browser_opened, browser_open_error) = + if matches!(request.mode, crate::agent::types::ElicitationMode::Url) { + request.url.as_deref().map_or( + (false, Some("SDK did not provide an auth URL".to_owned())), + |url| match open_url_in_browser(url) { + Ok(()) => (true, None), + Err(error) => (false, Some(error)), + }, + ) + } else { + (false, None) + }; + app.config.overlay = Some(ConfigOverlayState::McpElicitation(McpElicitationOverlayState { + request, + selected_index: 0, + browser_opened, + browser_open_error, + })); + app.config.last_error = None; +} + +pub(crate) fn present_mcp_auth_redirect( + app: &mut App, + redirect: crate::agent::types::McpAuthRedirect, +) { + view::set_active_view(app, ActiveView::Config); + app.config.active_tab = ConfigTab::Mcp; + refresh_mcp_snapshot(app); + let (browser_opened, browser_open_error) = match open_url_in_browser(&redirect.auth_url) { + Ok(()) => (true, None), + Err(error) => (false, Some(error)), + }; + app.config.overlay = Some(ConfigOverlayState::McpAuthRedirect(McpAuthRedirectOverlayState { + redirect, + selected_index: 0, + browser_opened, + browser_open_error, + })); + app.config.last_error = None; +} + +pub(crate) fn handle_mcp_elicitation_completed( + app: &mut App, + elicitation_id: &str, + _server_name: Option, +) { + let should_clear = app + .mcp + .pending_elicitation + .as_ref() + .and_then(|request| request.elicitation_id.as_deref()) + .is_some_and(|current| current == elicitation_id); + if should_clear { + app.mcp.pending_elicitation = None; + if matches!(app.config.overlay, Some(ConfigOverlayState::McpElicitation(_))) { + app.config.overlay = None; + } + refresh_mcp_snapshot(app); + } +} + +pub(crate) fn handle_mcp_operation_error( + app: &mut App, + error: &crate::agent::types::McpOperationError, +) { + app.mcp.in_flight = false; + let formatted = format_mcp_operation_error(error); + app.mcp.last_error = Some(formatted.clone()); + app.config.last_error = Some(formatted); + app.config.status_message = None; +} + +fn format_mcp_operation_error(error: &crate::agent::types::McpOperationError) -> String { + let action = match error.operation.as_str() { + "authenticate" => "authenticate", + "clear-auth" => "clear auth for", + "reconnect" => "reconnect", + "toggle" => "update", + "submit-callback-url" => "submit callback URL for", + other => other, + }; + match error.server_name.as_deref() { + Some(server_name) => { + format!("Failed to {action} MCP server {server_name}: {}", error.message) + } + None => format!("MCP operation failed ({action}): {}", error.message), + } +} + +fn open_url_in_browser(url: &str) -> Result<(), String> { + #[cfg(target_os = "windows")] + let mut command = { + let mut cmd = std::process::Command::new("rundll32.exe"); + cmd.args(["url.dll,FileProtocolHandler", url]); + cmd + }; + #[cfg(target_os = "macos")] + let mut command = { + let mut cmd = std::process::Command::new("open"); + cmd.arg(url); + cmd + }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = { + let mut cmd = std::process::Command::new("xdg-open"); + cmd.arg(url); + cmd + }; + + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Failed to open browser automatically: {error}")) +} + +pub(crate) fn copy_text_to_clipboard(text: &str) -> Result<(), String> { + let mut clipboard = arboard::Clipboard::new() + .map_err(|error| format!("Failed to access clipboard: {error}"))?; + clipboard + .set_text(text.to_owned()) + .map_err(|error| format!("Failed to copy to clipboard: {error}")) +} diff --git a/claude-code-rust/src/app/config/mcp_edit.rs b/claude-code-rust/src/app/config/mcp_edit.rs new file mode 100644 index 0000000..328a60a --- /dev/null +++ b/claude-code-rust/src/app/config/mcp_edit.rs @@ -0,0 +1,317 @@ +use super::ConfigOverlayState; +use super::edit::{ + TextInputOverlay, accepts_text_input, delete_text_at_cursor, delete_text_before_cursor, + insert_text_char, insert_text_str, move_text_cursor_left, move_text_cursor_right, + move_text_cursor_to_end, set_text_cursor, step_index_clamped, +}; +use super::mcp::{ + McpCallbackUrlOverlayState, McpServerActionKind, authenticate_mcp_server, + available_mcp_actions, clear_mcp_server_auth, copy_text_to_clipboard, is_mcp_action_available, + open_mcp_server_details, reconnect_mcp_server, refresh_mcp_snapshot, + send_mcp_elicitation_response, set_mcp_server_enabled, submit_mcp_oauth_callback_url, +}; +use crate::app::App; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +pub(super) fn handle_overlay_key(app: &mut App, key: KeyEvent) -> bool { + match app.config.overlay.clone() { + Some(ConfigOverlayState::McpDetails(_)) => { + handle_mcp_details_overlay_key(app, key); + true + } + Some(ConfigOverlayState::McpCallbackUrl(_)) => { + handle_mcp_callback_url_overlay_key(app, key); + true + } + Some(ConfigOverlayState::McpAuthRedirect(_)) => { + handle_mcp_auth_redirect_overlay_key(app, key); + true + } + Some(ConfigOverlayState::McpElicitation(_)) => { + handle_mcp_elicitation_overlay_key(app, key); + true + } + _ => false, + } +} + +pub(super) fn handle_overlay_paste(app: &mut App, text: &str) -> bool { + match app.config.overlay { + Some(ConfigOverlayState::McpCallbackUrl(_)) => { + insert_text_str(app.config.mcp_callback_url_overlay_mut(), text); + true + } + _ => false, + } +} + +fn handle_mcp_details_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Up, KeyModifiers::NONE) => move_mcp_details_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_mcp_details_overlay_selection(app, 1), + (KeyCode::Enter, KeyModifiers::NONE) => execute_selected_mcp_overlay_action(app), + _ => {} + } +} + +fn move_mcp_details_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.mcp_details_overlay().cloned() else { + return; + }; + let Some(server) = app.mcp.servers.iter().find(|server| server.name == overlay.server_name) + else { + return; + }; + let actions = available_mcp_actions(server); + if actions.is_empty() { + return; + } + + let next_index = step_index_clamped(overlay.selected_index, delta, actions.len()); + if let Some(state) = app.config.mcp_details_overlay_mut() { + state.selected_index = next_index; + } +} + +fn execute_selected_mcp_overlay_action(app: &mut App) { + let Some(overlay) = app.config.mcp_details_overlay().cloned() else { + return; + }; + let Some(server) = app.mcp.servers.iter().find(|server| server.name == overlay.server_name) + else { + app.config.overlay = None; + return; + }; + let actions = available_mcp_actions(server); + let Some(action) = actions.get(overlay.selected_index).copied() else { + return; + }; + if !is_mcp_action_available(server, action) { + return; + } + + match action { + McpServerActionKind::RefreshSnapshot => refresh_mcp_snapshot(app), + McpServerActionKind::Authenticate => { + authenticate_mcp_server(app, &overlay.server_name); + } + McpServerActionKind::ClearAuth => { + clear_mcp_server_auth(app, &overlay.server_name); + } + McpServerActionKind::Reconnect => { + reconnect_mcp_server(app, &overlay.server_name); + } + McpServerActionKind::Enable => { + set_mcp_server_enabled(app, &overlay.server_name, true); + } + McpServerActionKind::Disable => { + set_mcp_server_enabled(app, &overlay.server_name, false); + } + } + + app.config.overlay = None; +} + +fn handle_mcp_callback_url_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => confirm_mcp_callback_url_overlay(app), + (KeyCode::Esc, KeyModifiers::NONE) => cancel_mcp_callback_url_overlay(app), + (KeyCode::Left, KeyModifiers::NONE) => { + move_text_cursor_left(app.config.mcp_callback_url_overlay_mut()); + } + (KeyCode::Right, KeyModifiers::NONE) => { + move_text_cursor_right(app.config.mcp_callback_url_overlay_mut()); + } + (KeyCode::Home, KeyModifiers::NONE) => { + set_text_cursor(app.config.mcp_callback_url_overlay_mut(), 0); + } + (KeyCode::End, KeyModifiers::NONE) => { + move_text_cursor_to_end(app.config.mcp_callback_url_overlay_mut()); + } + (KeyCode::Backspace, KeyModifiers::NONE) => { + delete_text_before_cursor(app.config.mcp_callback_url_overlay_mut()); + } + (KeyCode::Delete, KeyModifiers::NONE) => { + delete_text_at_cursor(app.config.mcp_callback_url_overlay_mut()); + } + (KeyCode::Char(ch), modifiers) if accepts_text_input(modifiers) => { + insert_text_char(app.config.mcp_callback_url_overlay_mut(), ch); + } + _ => {} + } +} + +fn cancel_mcp_callback_url_overlay(app: &mut App) { + let Some(server_name) = + app.config.mcp_callback_url_overlay().map(|overlay| overlay.server_name.clone()) + else { + app.config.overlay = None; + return; + }; + open_mcp_server_details(app, server_name, Some(McpServerActionKind::Authenticate)); +} + +fn confirm_mcp_callback_url_overlay(app: &mut App) { + let Some(overlay) = app.config.mcp_callback_url_overlay().cloned() else { + return; + }; + let callback_url = overlay.draft.trim().to_owned(); + if callback_url.is_empty() { + app.config.last_error = Some("Callback URL cannot be empty".to_owned()); + app.config.status_message = None; + return; + } + + submit_mcp_oauth_callback_url(app, &overlay.server_name, callback_url); + app.config.overlay = None; +} + +fn handle_mcp_elicitation_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Esc, KeyModifiers::NONE) => cancel_mcp_elicitation_overlay(app), + (KeyCode::Up, KeyModifiers::NONE) => move_mcp_elicitation_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_mcp_elicitation_overlay_selection(app, 1), + (KeyCode::Enter, KeyModifiers::NONE) => execute_mcp_elicitation_overlay_action(app), + _ => {} + } +} + +fn cancel_mcp_elicitation_overlay(app: &mut App) { + let Some(request_id) = + app.config.mcp_elicitation_overlay().map(|overlay| overlay.request.request_id.clone()) + else { + app.config.overlay = None; + return; + }; + send_mcp_elicitation_response( + app, + &request_id, + crate::agent::types::ElicitationAction::Cancel, + None, + ); + app.config.overlay = None; +} + +#[derive(Clone, Copy)] +enum McpAuthRedirectAction { + Refresh, + CopyUrl, + Close, +} + +fn mcp_auth_redirect_actions() -> [McpAuthRedirectAction; 3] { + [McpAuthRedirectAction::Refresh, McpAuthRedirectAction::CopyUrl, McpAuthRedirectAction::Close] +} + +fn handle_mcp_auth_redirect_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Up, KeyModifiers::NONE) => move_mcp_auth_redirect_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_mcp_auth_redirect_overlay_selection(app, 1), + (KeyCode::Enter, KeyModifiers::NONE) => execute_mcp_auth_redirect_overlay_action(app), + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + _ => {} + } +} + +fn move_mcp_auth_redirect_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.mcp_auth_redirect_overlay().cloned() else { + return; + }; + let actions = mcp_auth_redirect_actions(); + let next_index = step_index_clamped(overlay.selected_index, delta, actions.len()); + if let Some(state) = app.config.mcp_auth_redirect_overlay_mut() { + state.selected_index = next_index; + } +} + +fn execute_mcp_auth_redirect_overlay_action(app: &mut App) { + let Some(overlay) = app.config.mcp_auth_redirect_overlay().cloned() else { + return; + }; + let actions = mcp_auth_redirect_actions(); + let Some(action) = actions.get(overlay.selected_index).copied() else { + return; + }; + match action { + McpAuthRedirectAction::Refresh => { + refresh_mcp_snapshot(app); + app.config.overlay = None; + } + McpAuthRedirectAction::CopyUrl => { + match copy_text_to_clipboard(&overlay.redirect.auth_url) { + Ok(()) => { + app.config.status_message = Some("Copied auth URL to clipboard.".to_owned()); + app.config.last_error = None; + } + Err(error) => { + app.config.last_error = Some(error); + app.config.status_message = None; + } + } + } + McpAuthRedirectAction::Close => { + app.config.overlay = None; + } + } +} + +fn move_mcp_elicitation_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.mcp_elicitation_overlay().cloned() else { + return; + }; + let actions = mcp_elicitation_actions(&overlay.request); + if actions.is_empty() { + return; + } + let next_index = step_index_clamped(overlay.selected_index, delta, actions.len()); + if let Some(state) = app.config.mcp_elicitation_overlay_mut() { + state.selected_index = next_index; + } +} + +fn execute_mcp_elicitation_overlay_action(app: &mut App) { + let Some(overlay) = app.config.mcp_elicitation_overlay().cloned() else { + return; + }; + let actions = mcp_elicitation_actions(&overlay.request); + let Some(action) = actions.get(overlay.selected_index).copied() else { + return; + }; + send_mcp_elicitation_response(app, &overlay.request.request_id, action, None); + app.config.overlay = None; +} + +fn mcp_elicitation_actions( + request: &crate::agent::types::ElicitationRequest, +) -> Vec { + match request.mode { + crate::agent::types::ElicitationMode::Url => vec![ + crate::agent::types::ElicitationAction::Accept, + crate::agent::types::ElicitationAction::Decline, + crate::agent::types::ElicitationAction::Cancel, + ], + crate::agent::types::ElicitationMode::Form => vec![ + crate::agent::types::ElicitationAction::Decline, + crate::agent::types::ElicitationAction::Cancel, + ], + } +} + +impl TextInputOverlay for McpCallbackUrlOverlayState { + fn draft(&self) -> &str { + &self.draft + } + + fn draft_mut(&mut self) -> &mut String { + &mut self.draft + } + + fn cursor(&self) -> usize { + self.cursor + } + + fn cursor_mut(&mut self) -> &mut usize { + &mut self.cursor + } +} diff --git a/claude-code-rust/src/app/config/mod.rs b/claude-code-rust/src/app/config/mod.rs new file mode 100644 index 0000000..3d08846 --- /dev/null +++ b/claude-code-rust/src/app/config/mod.rs @@ -0,0 +1,1597 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +mod edit; +mod mcp; +mod mcp_edit; +mod resolve; +pub mod store; + +use super::view::{self, ActiveView}; +use crate::agent::model::EffortLevel; +use crate::app::App; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::path::PathBuf; + +pub(crate) use edit::{ + OverlayModelOption, model_overlay_options, supported_effort_levels_for_model, +}; +pub(crate) use mcp::{ + McpAuthRedirectOverlayState, McpCallbackUrlOverlayState, McpDetailsOverlayState, + McpElicitationOverlayState, available_mcp_actions, handle_mcp_elicitation_completed, + handle_mcp_operation_error, is_mcp_action_available, present_mcp_auth_redirect, + present_mcp_elicitation_request, refresh_mcp_snapshot, +}; +pub(crate) use resolve::language_input_validation_message; +use resolve::resolve_setting_document; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigTab { + Settings, + Plugins, + Status, + Usage, + Mcp, +} + +impl ConfigTab { + pub const ALL: [Self; 5] = + [Self::Settings, Self::Plugins, Self::Status, Self::Usage, Self::Mcp]; + + pub const fn title(self) -> &'static str { + match self { + Self::Settings => "Settings", + Self::Plugins => "Plugins", + Self::Status => "Status", + Self::Usage => "Usage", + Self::Mcp => "MCP", + } + } + + const fn next(self) -> Self { + match self { + Self::Settings => Self::Plugins, + Self::Plugins => Self::Status, + Self::Status => Self::Usage, + Self::Usage => Self::Mcp, + Self::Mcp => Self::Settings, + } + } + + const fn prev(self) -> Self { + match self { + Self::Settings => Self::Mcp, + Self::Plugins => Self::Settings, + Self::Status => Self::Plugins, + Self::Usage => Self::Status, + Self::Mcp => Self::Usage, + } + } +} + +#[repr(usize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SettingId { + AlwaysThinking, + Model, + DefaultPermissionMode, + EditorMode, + FastMode, + Language, + Notifications, + OutputStyle, + ReduceMotion, + RespectGitignore, + ShowTips, + TerminalProgressBar, + Theme, + ThinkingEffort, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingKind { + Bool, + Enum, + DynamicEnum, + Text, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditorKind { + Toggle, + Cycle, + Overlay, + Search, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValueSource { + PersistedOnly, + RuntimeBacked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingFile { + Settings, + LocalSettings, + Preferences, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeCatalogKind { + Models, + PermissionModes, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FallbackPolicy { + None, + AppDefault, + English, + RuntimeDefault, + Unset, +} + +impl FallbackPolicy { + #[must_use] + pub const fn short_label(self) -> &'static str { + match self { + Self::None => "current value", + Self::AppDefault => "default", + Self::English => "English", + Self::RuntimeDefault => "runtime default", + Self::Unset => "unset", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SettingOption { + pub stored: &'static str, + pub label: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingOptions { + None, + Static(&'static [SettingOption]), + RuntimeCatalog(RuntimeCatalogKind), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SettingSpec { + pub id: SettingId, + pub entry_id: &'static str, + pub label: &'static str, + pub description: &'static str, + pub file: SettingFile, + pub json_path: &'static [&'static str], + pub kind: SettingKind, + pub editor: EditorKind, + pub source: ValueSource, + pub options: SettingOptions, + pub fallback: FallbackPolicy, + pub supported: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DefaultPermissionMode { + #[default] + Default, + AcceptEdits, + Plan, + DontAsk, + BypassPermissions, +} + +impl DefaultPermissionMode { + #[must_use] + pub const fn as_stored(self) -> &'static str { + match self { + Self::Default => "default", + Self::AcceptEdits => "acceptEdits", + Self::Plan => "plan", + Self::DontAsk => "dontAsk", + Self::BypassPermissions => "bypassPermissions", + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Default => "Default", + Self::AcceptEdits => "Accept Edits", + Self::Plan => "Plan", + Self::DontAsk => "Don't Ask", + Self::BypassPermissions => "Bypass Permissions", + } + } + + #[must_use] + pub fn from_stored(value: &str) -> Option { + match value { + "default" => Some(Self::Default), + "acceptEdits" => Some(Self::AcceptEdits), + "plan" => Some(Self::Plan), + "dontAsk" => Some(Self::DontAsk), + "bypassPermissions" => Some(Self::BypassPermissions), + _ => None, + } + } + + #[must_use] + pub const fn next(self) -> Self { + match self { + Self::Default => Self::AcceptEdits, + Self::AcceptEdits => Self::Plan, + Self::Plan => Self::DontAsk, + Self::DontAsk => Self::BypassPermissions, + Self::BypassPermissions => Self::Default, + } + } + + #[must_use] + pub const fn prev(self) -> Self { + match self { + Self::Default => Self::BypassPermissions, + Self::AcceptEdits => Self::Default, + Self::Plan => Self::AcceptEdits, + Self::DontAsk => Self::Plan, + Self::BypassPermissions => Self::DontAsk, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PreferredNotifChannel { + #[default] + Iterm2, + Iterm2WithBell, + TerminalBell, + NotificationsDisabled, + Ghostty, +} + +impl PreferredNotifChannel { + #[must_use] + pub const fn as_stored(self) -> &'static str { + match self { + Self::Iterm2 => "iterm2", + Self::Iterm2WithBell => "iterm2_with_bell", + Self::TerminalBell => "terminal_bell", + Self::NotificationsDisabled => "notifications_disabled", + Self::Ghostty => "ghostty", + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Iterm2 => "Auto / iTerm2", + Self::Iterm2WithBell => "iTerm2 with Bell", + Self::TerminalBell => "Terminal Bell", + Self::NotificationsDisabled => "Disabled", + Self::Ghostty => "Ghostty", + } + } + + #[must_use] + pub fn from_stored(value: &str) -> Option { + match value { + "iterm2" => Some(Self::Iterm2), + "iterm2_with_bell" => Some(Self::Iterm2WithBell), + "terminal_bell" => Some(Self::TerminalBell), + "notifications_disabled" => Some(Self::NotificationsDisabled), + "ghostty" => Some(Self::Ghostty), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutputStyle { + #[default] + Default, + Explanatory, + Learning, +} + +impl OutputStyle { + pub const ALL: [Self; 3] = [Self::Default, Self::Explanatory, Self::Learning]; + + #[must_use] + pub const fn as_stored(self) -> &'static str { + match self { + Self::Default => "Default", + Self::Explanatory => "Explanatory", + Self::Learning => "Learning", + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + self.as_stored() + } + + #[must_use] + pub const fn description(self) -> &'static str { + match self { + Self::Default => { + "Claude completes coding tasks efficiently and provides concise responses" + } + Self::Explanatory => "Claude explains its implementation choices and codebase patterns", + Self::Learning => { + "Claude pauses and asks you to write small pieces of code for hands-on practice" + } + } + } + + #[must_use] + pub fn from_stored(value: &str) -> Option { + match value { + "Default" => Some(Self::Default), + "Explanatory" => Some(Self::Explanatory), + "Learning" => Some(Self::Learning), + _ => None, + } + } +} + +const DEFAULT_PERMISSION_OPTIONS: &[SettingOption] = &[ + SettingOption { stored: "default", label: "Default" }, + SettingOption { stored: "acceptEdits", label: "Accept Edits" }, + SettingOption { stored: "plan", label: "Plan" }, + SettingOption { stored: "dontAsk", label: "Don't Ask" }, + SettingOption { stored: "bypassPermissions", label: "Bypass Permissions" }, +]; + +const NOTIFICATION_OPTIONS: &[SettingOption] = &[ + SettingOption { stored: "iterm2", label: "Auto / iTerm2" }, + SettingOption { stored: "iterm2_with_bell", label: "iTerm2 with Bell" }, + SettingOption { stored: "terminal_bell", label: "Terminal Bell" }, + SettingOption { stored: "ghostty", label: "Ghostty" }, + SettingOption { stored: "notifications_disabled", label: "Disabled" }, +]; + +const OUTPUT_STYLE_OPTIONS: &[SettingOption] = &[ + SettingOption { stored: "Default", label: "Default" }, + SettingOption { stored: "Explanatory", label: "Explanatory" }, + SettingOption { stored: "Learning", label: "Learning" }, +]; + +const THEME_OPTIONS: &[SettingOption] = &[ + SettingOption { stored: "dark", label: "Dark" }, + SettingOption { stored: "light", label: "Light" }, + SettingOption { stored: "light-daltonized", label: "Light (Daltonized)" }, + SettingOption { stored: "dark-daltonized", label: "Dark (Daltonized)" }, +]; + +const EDITOR_MODE_OPTIONS: &[SettingOption] = &[ + SettingOption { stored: "default", label: "Default" }, + SettingOption { stored: "vim", label: "Vim" }, +]; +const DEFAULT_MODEL_ID: &str = "default"; +const DEFAULT_MODEL_LABEL: &str = "Default"; +const DEFAULT_EFFORT_LEVELS: [EffortLevel; 3] = + [EffortLevel::Low, EffortLevel::Medium, EffortLevel::High]; +const LANGUAGE_MIN_CHARS: usize = 2; +const LANGUAGE_MAX_CHARS: usize = 30; + +const EFFORT_OPTIONS: &[SettingOption] = &[ + SettingOption { stored: "low", label: "Low" }, + SettingOption { stored: "medium", label: "Medium" }, + SettingOption { stored: "high", label: "High" }, +]; + +const CONFIG_SETTINGS: [SettingSpec; 14] = [ + SettingSpec { + id: SettingId::AlwaysThinking, + entry_id: "A04", + label: "Always Thinking", + description: "Enable adaptive thinking for new sessions. When off, new sessions start with thinking disabled.", + file: SettingFile::Settings, + json_path: &["alwaysThinkingEnabled"], + kind: SettingKind::Bool, + editor: EditorKind::Toggle, + source: ValueSource::PersistedOnly, + options: SettingOptions::None, + fallback: FallbackPolicy::AppDefault, + supported: true, + }, + SettingSpec { + id: SettingId::Model, + entry_id: "A19", + label: "Default model", + description: "Sets the default model for new sessions and opens the combined model and thinking effort picker.", + file: SettingFile::Settings, + json_path: &["model"], + kind: SettingKind::DynamicEnum, + editor: EditorKind::Overlay, + source: ValueSource::RuntimeBacked, + options: SettingOptions::RuntimeCatalog(RuntimeCatalogKind::Models), + fallback: FallbackPolicy::RuntimeDefault, + supported: true, + }, + SettingSpec { + id: SettingId::DefaultPermissionMode, + entry_id: "A09", + label: "Default permission mode", + description: "Sets the default approval behavior for future sessions.", + file: SettingFile::Settings, + json_path: &["permissions", "defaultMode"], + kind: SettingKind::DynamicEnum, + editor: EditorKind::Cycle, + source: ValueSource::RuntimeBacked, + options: SettingOptions::RuntimeCatalog(RuntimeCatalogKind::PermissionModes), + fallback: FallbackPolicy::RuntimeDefault, + supported: true, + }, + SettingSpec { + id: SettingId::EditorMode, + entry_id: "A17", + label: "Editor mode", + description: "Controls how text editing keys behave in the TUI.", + file: SettingFile::Preferences, + json_path: &["editorMode"], + kind: SettingKind::Enum, + editor: EditorKind::Cycle, + source: ValueSource::PersistedOnly, + options: SettingOptions::Static(EDITOR_MODE_OPTIONS), + fallback: FallbackPolicy::AppDefault, + supported: false, + }, + SettingSpec { + id: SettingId::FastMode, + entry_id: "A05", + label: "Fast mode", + description: "Controls the persisted fast-mode preference for future sessions.", + file: SettingFile::Settings, + json_path: &["fastMode"], + kind: SettingKind::Bool, + editor: EditorKind::Toggle, + source: ValueSource::PersistedOnly, + options: SettingOptions::None, + fallback: FallbackPolicy::AppDefault, + supported: true, + }, + SettingSpec { + id: SettingId::Language, + entry_id: "A16", + label: "Language", + description: "Controls the free-text language instruction Claude uses in sessions. Accepts 2 to 30 characters and does not localize the UI.", + file: SettingFile::Settings, + json_path: &["language"], + kind: SettingKind::Text, + editor: EditorKind::Overlay, + source: ValueSource::PersistedOnly, + options: SettingOptions::None, + fallback: FallbackPolicy::Unset, + supported: true, + }, + SettingSpec { + id: SettingId::Notifications, + entry_id: "A14", + label: "Notifications", + description: "Controls how Claude Code notifies you when attention is needed.", + file: SettingFile::Preferences, + json_path: &["preferredNotifChannel"], + kind: SettingKind::Enum, + editor: EditorKind::Cycle, + source: ValueSource::PersistedOnly, + options: SettingOptions::Static(NOTIFICATION_OPTIONS), + fallback: FallbackPolicy::AppDefault, + supported: true, + }, + SettingSpec { + id: SettingId::OutputStyle, + entry_id: "A15", + label: "Output style", + description: "Changes how Claude communicates with you in sessions.", + file: SettingFile::LocalSettings, + json_path: &["outputStyle"], + kind: SettingKind::Enum, + editor: EditorKind::Overlay, + source: ValueSource::PersistedOnly, + options: SettingOptions::Static(OUTPUT_STYLE_OPTIONS), + fallback: FallbackPolicy::AppDefault, + supported: true, + }, + SettingSpec { + id: SettingId::ReduceMotion, + entry_id: "A03", + label: "Reduce motion", + description: "Reduce UI motion by slowing spinners and disabling smooth chat scrolling.", + file: SettingFile::LocalSettings, + json_path: &["prefersReducedMotion"], + kind: SettingKind::Bool, + editor: EditorKind::Toggle, + source: ValueSource::PersistedOnly, + options: SettingOptions::None, + fallback: FallbackPolicy::AppDefault, + supported: true, + }, + SettingSpec { + id: SettingId::RespectGitignore, + entry_id: "A10", + label: "Respect .gitignore", + description: "Controls whether @ file mentions hide entries ignored by git ignore rules.", + file: SettingFile::Preferences, + json_path: &["respectGitignore"], + kind: SettingKind::Bool, + editor: EditorKind::Toggle, + source: ValueSource::PersistedOnly, + options: SettingOptions::None, + fallback: FallbackPolicy::AppDefault, + supported: true, + }, + SettingSpec { + id: SettingId::ShowTips, + entry_id: "A02", + label: "Show Tips", + description: "Controls whether Claude shows spinner tips in supported clients.", + file: SettingFile::LocalSettings, + json_path: &["spinnerTipsEnabled"], + kind: SettingKind::Bool, + editor: EditorKind::Toggle, + source: ValueSource::PersistedOnly, + options: SettingOptions::None, + fallback: FallbackPolicy::AppDefault, + supported: false, + }, + SettingSpec { + id: SettingId::TerminalProgressBar, + entry_id: "A08", + label: "Terminal progress bar", + description: "Controls whether Claude should show its terminal progress bar in supported clients.", + file: SettingFile::Preferences, + json_path: &["terminalProgressBarEnabled"], + kind: SettingKind::Bool, + editor: EditorKind::Toggle, + source: ValueSource::PersistedOnly, + options: SettingOptions::None, + fallback: FallbackPolicy::AppDefault, + supported: false, + }, + SettingSpec { + id: SettingId::Theme, + entry_id: "A13", + label: "Theme", + description: "Controls the TUI color theme.", + file: SettingFile::Preferences, + json_path: &["theme"], + kind: SettingKind::Enum, + editor: EditorKind::Cycle, + source: ValueSource::PersistedOnly, + options: SettingOptions::Static(THEME_OPTIONS), + fallback: FallbackPolicy::AppDefault, + supported: false, + }, + SettingSpec { + id: SettingId::ThinkingEffort, + entry_id: "A20", + label: "Thinking effort", + description: "Controls how much effort Claude uses when thinking for new sessions. Only applies when Always Thinking is on and the selected model supports effort.", + file: SettingFile::Settings, + json_path: &["effortLevel"], + kind: SettingKind::Enum, + editor: EditorKind::Overlay, + source: ValueSource::PersistedOnly, + options: SettingOptions::Static(EFFORT_OPTIONS), + fallback: FallbackPolicy::AppDefault, + supported: true, + }, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingValidation { + Valid, + InvalidValue, + UnavailableOption, +} + +impl SettingValidation { + #[must_use] + pub const fn is_invalid(self) -> bool { + !matches!(self, Self::Valid) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedChoice { + Automatic, + Stored(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedSettingValue { + Bool(bool), + Choice(ResolvedChoice), + Text(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedSetting { + pub value: ResolvedSettingValue, + pub validation: SettingValidation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverlayFocus { + Model, + Effort, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelAndEffortOverlayState { + pub focus: OverlayFocus, + pub selected_model: String, + pub selected_effort: EffortLevel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutputStyleOverlayState { + pub selected: OutputStyle, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LanguageOverlayState { + pub draft: String, + pub cursor: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionRenameOverlayState { + pub draft: String, + pub cursor: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarketplaceActionKind { + Update, + Remove, +} + +impl MarketplaceActionKind { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Update => "Update", + Self::Remove => "Remove", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstalledPluginActionKind { + Enable, + Disable, + Update, + InstallInCurrentProject, + Uninstall, +} + +impl InstalledPluginActionKind { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Enable => "Enable", + Self::Disable => "Disable", + Self::Update => "Update", + Self::InstallInCurrentProject => "Install in current project", + Self::Uninstall => "Uninstall", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginInstallActionKind { + User, + Project, + Local, +} + +impl PluginInstallActionKind { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::User => "Install for user", + Self::Project => "Install for project", + Self::Local => "Install locally", + } + } + + #[must_use] + pub const fn scope(self) -> &'static str { + match self { + Self::User => "user", + Self::Project => "project", + Self::Local => "local", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledPluginActionOverlayState { + pub plugin_id: String, + pub title: String, + pub description: String, + pub scope: String, + pub project_path: Option, + pub selected_index: usize, + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInstallOverlayState { + pub plugin_id: String, + pub title: String, + pub description: String, + pub selected_index: usize, + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceActionsOverlayState { + pub name: String, + pub title: String, + pub description: String, + pub selected_index: usize, + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddMarketplaceOverlayState { + pub draft: String, + pub cursor: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConfigOverlayState { + ModelAndEffort(ModelAndEffortOverlayState), + OutputStyle(OutputStyleOverlayState), + Language(LanguageOverlayState), + SessionRename(SessionRenameOverlayState), + InstalledPluginActions(InstalledPluginActionOverlayState), + PluginInstallActions(PluginInstallOverlayState), + MarketplaceActions(MarketplaceActionsOverlayState), + AddMarketplace(AddMarketplaceOverlayState), + McpDetails(McpDetailsOverlayState), + McpCallbackUrl(McpCallbackUrlOverlayState), + McpElicitation(McpElicitationOverlayState), + McpAuthRedirect(McpAuthRedirectOverlayState), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingSessionTitleChangeKind { + Rename { requested_title: Option }, + Generate, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingSessionTitleChangeState { + pub session_id: String, + pub kind: PendingSessionTitleChangeKind, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConfigState { + pub active_tab: ConfigTab, + pub selected_setting_index: usize, + pub settings_scroll_offset: usize, + pub mcp_selected_server_index: usize, + pub overlay: Option, + pub committed_settings_document: Value, + pub committed_local_settings_document: Value, + pub committed_preferences_document: Value, + pub settings_path: Option, + pub local_settings_path: Option, + pub preferences_path: Option, + pub status_message: Option, + pub last_error: Option, + pub pending_session_title_change: Option, +} + +impl Default for ConfigState { + fn default() -> Self { + Self { + active_tab: ConfigTab::Settings, + selected_setting_index: 0, + settings_scroll_offset: 0, + mcp_selected_server_index: 0, + overlay: None, + committed_settings_document: Value::Object(serde_json::Map::new()), + committed_local_settings_document: Value::Object(serde_json::Map::new()), + committed_preferences_document: Value::Object(serde_json::Map::new()), + settings_path: None, + local_settings_path: None, + preferences_path: None, + status_message: None, + last_error: None, + pending_session_title_change: None, + } + } +} + +impl ConfigState { + #[must_use] + pub fn fast_mode_effective(&self) -> bool { + match resolve_setting_document(&self.committed_settings_document, SettingId::FastMode, &[]) + .value + { + ResolvedSettingValue::Bool(value) => value, + ResolvedSettingValue::Choice(_) | ResolvedSettingValue::Text(_) => false, + } + } + + #[must_use] + pub fn always_thinking_effective(&self) -> bool { + match resolve_setting_document( + &self.committed_settings_document, + SettingId::AlwaysThinking, + &[], + ) + .value + { + ResolvedSettingValue::Bool(value) => value, + ResolvedSettingValue::Choice(_) | ResolvedSettingValue::Text(_) => false, + } + } + + #[must_use] + pub fn model_effective(&self) -> Option { + match resolve_setting_document(&self.committed_settings_document, SettingId::Model, &[]) + .value + { + ResolvedSettingValue::Choice(ResolvedChoice::Automatic) => { + Some(DEFAULT_MODEL_ID.to_owned()) + } + ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)) => Some(value), + ResolvedSettingValue::Bool(_) | ResolvedSettingValue::Text(_) => None, + } + } + + #[must_use] + pub fn thinking_effort_effective(&self) -> EffortLevel { + store::thinking_effort_level(&self.committed_settings_document) + .unwrap_or(EffortLevel::Medium) + } + + #[must_use] + pub fn default_permission_mode_effective(&self) -> DefaultPermissionMode { + match resolve_setting_document( + &self.committed_settings_document, + SettingId::DefaultPermissionMode, + &[], + ) + .value + { + ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)) => { + DefaultPermissionMode::from_stored(&value).unwrap_or_default() + } + ResolvedSettingValue::Bool(_) + | ResolvedSettingValue::Choice(ResolvedChoice::Automatic) + | ResolvedSettingValue::Text(_) => DefaultPermissionMode::Default, + } + } + + #[must_use] + pub fn respect_gitignore_effective(&self) -> bool { + store::respect_gitignore(&self.committed_preferences_document).unwrap_or(true) + } + + #[must_use] + pub fn preferred_notification_channel_effective(&self) -> PreferredNotifChannel { + store::preferred_notification_channel(&self.committed_preferences_document) + .unwrap_or_default() + } + + #[must_use] + pub fn prefers_reduced_motion_effective(&self) -> bool { + store::prefers_reduced_motion(&self.committed_local_settings_document).unwrap_or(false) + } + + #[must_use] + pub fn output_style_effective(&self) -> OutputStyle { + store::output_style(&self.committed_local_settings_document).unwrap_or_default() + } + + #[must_use] + pub fn selected_setting_spec(&self) -> Option<&'static SettingSpec> { + setting_specs().get(self.selected_setting_index) + } + + #[must_use] + pub fn model_and_effort_overlay(&self) -> Option<&ModelAndEffortOverlayState> { + match &self.overlay { + Some(ConfigOverlayState::ModelAndEffort(overlay)) => Some(overlay), + Some( + ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + pub fn model_and_effort_overlay_mut(&mut self) -> Option<&mut ModelAndEffortOverlayState> { + match &mut self.overlay { + Some(ConfigOverlayState::ModelAndEffort(overlay)) => Some(overlay), + Some( + ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + #[must_use] + pub fn output_style_overlay(&self) -> Option<&OutputStyleOverlayState> { + match &self.overlay { + Some(ConfigOverlayState::OutputStyle(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + pub fn output_style_overlay_mut(&mut self) -> Option<&mut OutputStyleOverlayState> { + match &mut self.overlay { + Some(ConfigOverlayState::OutputStyle(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + #[must_use] + pub fn language_overlay(&self) -> Option<&LanguageOverlayState> { + match &self.overlay { + Some(ConfigOverlayState::Language(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + pub fn language_overlay_mut(&mut self) -> Option<&mut LanguageOverlayState> { + match &mut self.overlay { + Some(ConfigOverlayState::Language(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + #[must_use] + pub fn session_rename_overlay(&self) -> Option<&SessionRenameOverlayState> { + match &self.overlay { + Some(ConfigOverlayState::SessionRename(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + pub fn session_rename_overlay_mut(&mut self) -> Option<&mut SessionRenameOverlayState> { + match &mut self.overlay { + Some(ConfigOverlayState::SessionRename(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + #[must_use] + pub fn installed_plugin_actions_overlay(&self) -> Option<&InstalledPluginActionOverlayState> { + match &self.overlay { + Some(ConfigOverlayState::InstalledPluginActions(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + pub fn installed_plugin_actions_overlay_mut( + &mut self, + ) -> Option<&mut InstalledPluginActionOverlayState> { + match &mut self.overlay { + Some(ConfigOverlayState::InstalledPluginActions(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + #[must_use] + pub fn plugin_install_overlay(&self) -> Option<&PluginInstallOverlayState> { + match &self.overlay { + Some(ConfigOverlayState::PluginInstallActions(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + pub fn plugin_install_overlay_mut(&mut self) -> Option<&mut PluginInstallOverlayState> { + match &mut self.overlay { + Some(ConfigOverlayState::PluginInstallActions(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + #[must_use] + pub fn marketplace_actions_overlay(&self) -> Option<&MarketplaceActionsOverlayState> { + match &self.overlay { + Some(ConfigOverlayState::MarketplaceActions(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + pub fn marketplace_actions_overlay_mut( + &mut self, + ) -> Option<&mut MarketplaceActionsOverlayState> { + match &mut self.overlay { + Some(ConfigOverlayState::MarketplaceActions(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::AddMarketplace(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + #[must_use] + pub fn add_marketplace_overlay(&self) -> Option<&AddMarketplaceOverlayState> { + match &self.overlay { + Some(ConfigOverlayState::AddMarketplace(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + pub fn add_marketplace_overlay_mut(&mut self) -> Option<&mut AddMarketplaceOverlayState> { + match &mut self.overlay { + Some(ConfigOverlayState::AddMarketplace(overlay)) => Some(overlay), + Some( + ConfigOverlayState::ModelAndEffort(_) + | ConfigOverlayState::OutputStyle(_) + | ConfigOverlayState::Language(_) + | ConfigOverlayState::SessionRename(_) + | ConfigOverlayState::InstalledPluginActions(_) + | ConfigOverlayState::PluginInstallActions(_) + | ConfigOverlayState::MarketplaceActions(_) + | ConfigOverlayState::McpDetails(_) + | ConfigOverlayState::McpCallbackUrl(_) + | ConfigOverlayState::McpElicitation(_) + | ConfigOverlayState::McpAuthRedirect(_), + ) + | None => None, + } + } + + #[must_use] + pub fn path_for(&self, file: SettingFile) -> Option<&PathBuf> { + match file { + SettingFile::Settings => self.settings_path.as_ref(), + SettingFile::LocalSettings => self.local_settings_path.as_ref(), + SettingFile::Preferences => self.preferences_path.as_ref(), + } + } + + #[must_use] + pub fn document_for(&self, file: SettingFile) -> &Value { + match file { + SettingFile::Settings => &self.committed_settings_document, + SettingFile::LocalSettings => &self.committed_local_settings_document, + SettingFile::Preferences => &self.committed_preferences_document, + } + } + + pub fn committed_document_for_mut(&mut self, file: SettingFile) -> &mut Value { + match file { + SettingFile::Settings => &mut self.committed_settings_document, + SettingFile::LocalSettings => &mut self.committed_local_settings_document, + SettingFile::Preferences => &mut self.committed_preferences_document, + } + } + + fn apply_loaded( + &mut self, + loaded: store::LoadedSettingsDocuments, + notice: Option, + preserve_status: bool, + ) { + self.settings_path = Some(loaded.paths.settings); + self.local_settings_path = Some(loaded.paths.local_settings); + self.preferences_path = Some(loaded.paths.preferences); + self.committed_settings_document = loaded.settings_document; + self.committed_local_settings_document = loaded.local_settings_document; + self.committed_preferences_document = loaded.preferences_document; + self.overlay = None; + self.selected_setting_index = + self.selected_setting_index.min(setting_specs().len().saturating_sub(1)); + self.settings_scroll_offset = self.settings_scroll_offset.min(self.selected_setting_index); + self.mcp_selected_server_index = 0; + if !preserve_status { + self.status_message = notice; + self.last_error = None; + } else if let Some(notice) = notice { + self.status_message = Some(notice); + } + } +} + +#[must_use] +pub const fn setting_specs() -> &'static [SettingSpec] { + &CONFIG_SETTINGS +} + +#[must_use] +pub fn setting_spec(id: SettingId) -> &'static SettingSpec { + &CONFIG_SETTINGS[id as usize] +} + +#[must_use] +pub fn resolved_setting(app: &App, spec: &SettingSpec) -> ResolvedSetting { + let document = app.config.document_for(spec.file); + resolve_setting_document(document, spec.id, &app.available_models) +} + +#[must_use] +pub fn setting_display_value(app: &App, spec: &SettingSpec, resolved: &ResolvedSetting) -> String { + match (&resolved.value, spec.id) { + (ResolvedSettingValue::Bool(value), _) => { + if *value { + "On".to_owned() + } else { + "Off".to_owned() + } + } + (ResolvedSettingValue::Text(value), _) => { + if value.is_empty() { + "Not set".to_owned() + } else { + value.clone() + } + } + (ResolvedSettingValue::Choice(ResolvedChoice::Automatic), SettingId::Model) => { + DEFAULT_MODEL_LABEL.to_owned() + } + (ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)), SettingId::Model) => { + model_status_label(Some(value), app) + } + ( + ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)), + SettingId::ThinkingEffort, + ) => effort_level_label(value).unwrap_or_else(|| value.clone()), + (ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)), _) => { + option_label(spec, value).unwrap_or_else(|| value.clone()) + } + _ => String::new(), + } +} + +#[must_use] +pub fn setting_invalid_hint(spec: &SettingSpec, validation: SettingValidation) -> Option { + match validation { + SettingValidation::Valid => None, + SettingValidation::InvalidValue => { + Some(format!("invalid value, using {}", spec.fallback.short_label())) + } + SettingValidation::UnavailableOption if spec.id == SettingId::Model => { + Some("model not advertised by current SDK session".to_owned()) + } + SettingValidation::UnavailableOption => { + Some(format!("value unavailable, using {}", spec.fallback.short_label())) + } + } +} + +#[must_use] +pub fn setting_detail_options(app: &App, spec: &SettingSpec) -> Vec { + match spec.kind { + SettingKind::Bool => vec!["Off".to_owned(), "On".to_owned()], + SettingKind::Text => Vec::new(), + SettingKind::Enum | SettingKind::DynamicEnum => match spec.options { + SettingOptions::None => Vec::new(), + SettingOptions::Static(options) => { + options.iter().map(|option| option.label.to_owned()).collect() + } + SettingOptions::RuntimeCatalog(RuntimeCatalogKind::Models) => { + if app.available_models.is_empty() { + vec![ + DEFAULT_MODEL_LABEL.to_owned(), + "Connect to load available models".to_owned(), + ] + } else { + model_overlay_options(app) + .into_iter() + .map(|option| option.display_name) + .collect() + } + } + SettingOptions::RuntimeCatalog(RuntimeCatalogKind::PermissionModes) => { + DEFAULT_PERMISSION_OPTIONS.iter().map(|option| option.label.to_owned()).collect() + } + }, + } +} + +pub fn initialize_shared_state(app: &mut App) -> Result<(), String> { + let loaded = store::load(app.settings_home_override.as_deref(), Some(project_root(app)))?; + let notice = loaded.notice.clone(); + app.config.apply_loaded(loaded, notice, false); + app.reconcile_runtime_from_persisted_settings_change(); + Ok(()) +} + +pub fn open(app: &mut App) -> Result<(), String> { + if !app.is_project_trusted() { + return Err("Project trust must be accepted before opening settings".to_owned()); + } + + let loaded = store::load(app.settings_home_override.as_deref(), Some(project_root(app)))?; + let notice = loaded.notice.clone(); + app.config.apply_loaded(loaded, notice, false); + app.reconcile_runtime_from_persisted_settings_change(); + view::set_active_view(app, ActiveView::Config); + request_active_tab_side_effects(app); + Ok(()) +} + +pub(crate) fn refresh_runtime_tabs_for_session_change(app: &mut App) { + if app.active_view != ActiveView::Config { + return; + } + request_status_snapshot_if_needed(app); + if app.config.active_tab == ConfigTab::Usage { + crate::app::usage::request_refresh_if_needed(app); + } + if app.config.active_tab == ConfigTab::Plugins { + crate::app::plugins::request_inventory_refresh_if_needed(app); + } +} + +pub fn close(app: &mut App) { + view::set_active_view(app, ActiveView::Chat); +} + +pub(crate) fn activate_tab(app: &mut App, tab: ConfigTab) { + app.config.active_tab = tab; + app.config.status_message = None; + app.config.last_error = None; + request_active_tab_side_effects(app); +} + +pub fn handle_key(app: &mut App, key: KeyEvent) { + if is_ctrl_shortcut(key, 'q') || is_ctrl_shortcut(key, 'c') { + app.should_quit = true; + return; + } + + if app.config.overlay.is_some() { + edit::handle_overlay_key(app, key); + return; + } + + if app.config.active_tab == ConfigTab::Plugins && crate::app::plugins::handle_key(app, key) { + return; + } + if mcp::handle_mcp_key(app, key) { + return; + } + + match (key.code, key.modifiers) { + (KeyCode::Char(' '), KeyModifiers::NONE) + if app.config.active_tab == ConfigTab::Settings => + { + if let Some(spec) = app.config.selected_setting_spec() { + edit::activate_setting(app, spec); + } + } + (KeyCode::Left, KeyModifiers::NONE) if app.config.active_tab == ConfigTab::Settings => { + if let Some(spec) = app.config.selected_setting_spec() { + edit::step_setting(app, spec, -1); + } + } + (KeyCode::Right, KeyModifiers::NONE) if app.config.active_tab == ConfigTab::Settings => { + if let Some(spec) = app.config.selected_setting_spec() { + edit::step_setting(app, spec, 1); + } + } + (KeyCode::Char(ch), modifiers) + if app.config.active_tab == ConfigTab::Status + && matches!(ch, 'r' | 'R') + && (modifiers.is_empty() || modifiers == KeyModifiers::SHIFT) => + { + edit::open_session_rename_overlay(app); + } + (KeyCode::Char(ch), modifiers) + if app.config.active_tab == ConfigTab::Status + && matches!(ch, 'g' | 'G') + && (modifiers.is_empty() || modifiers == KeyModifiers::SHIFT) => + { + edit::generate_session_title(app); + } + (KeyCode::Char(ch), modifiers) + if app.config.active_tab == ConfigTab::Usage + && matches!(ch, 'r' | 'R') + && (modifiers.is_empty() || modifiers == KeyModifiers::SHIFT) => + { + crate::app::usage::request_refresh(app); + } + (KeyCode::Enter | KeyCode::Esc, KeyModifiers::NONE) => { + close(app); + } + (KeyCode::BackTab, _) => { + activate_tab(app, app.config.active_tab.prev()); + } + (KeyCode::Tab, KeyModifiers::NONE) => { + activate_tab(app, app.config.active_tab.next()); + } + (KeyCode::Up, KeyModifiers::NONE) => { + if app.config.active_tab == ConfigTab::Settings { + app.config.selected_setting_index = + app.config.selected_setting_index.saturating_sub(1); + } + } + (KeyCode::Down, KeyModifiers::NONE) => { + if app.config.active_tab == ConfigTab::Settings { + let last_index = setting_specs().len().saturating_sub(1); + app.config.selected_setting_index = + (app.config.selected_setting_index + 1).min(last_index); + } + } + _ => {} + } +} + +pub fn handle_paste(app: &mut App, text: &str) -> bool { + if app.config.overlay.is_some() { + return edit::handle_overlay_paste(app, text); + } + if app.config.active_tab == ConfigTab::Plugins { + return crate::app::plugins::handle_paste(app, text); + } + false +} + +fn request_active_tab_side_effects(app: &mut App) { + request_status_snapshot_if_needed(app); + mcp::refresh_mcp_snapshot_if_needed(app); + if app.config.active_tab == ConfigTab::Usage { + crate::app::usage::request_refresh_if_needed(app); + } + if app.config.active_tab == ConfigTab::Plugins { + crate::app::plugins::request_inventory_refresh_if_needed(app); + } +} + +fn is_ctrl_shortcut(key: KeyEvent, ch: char) -> bool { + matches!(key.code, KeyCode::Char(candidate) if candidate == ch) + && key.modifiers == KeyModifiers::CONTROL +} + +/// Send a `get_status_snapshot` command when the Status tab is active. +pub fn request_status_snapshot_if_needed(app: &App) { + if app.config.active_tab != ConfigTab::Status { + return; + } + let Some(conn) = app.conn.as_ref() else { + return; + }; + let Some(ref sid) = app.session_id else { + return; + }; + let _ = conn.get_status_snapshot(sid.to_string()); +} + +pub(crate) fn model_status_label(model: Option<&str>, app: &App) -> String { + match model { + None => DEFAULT_MODEL_LABEL.to_owned(), + Some(model_id) => model_overlay_options(app) + .into_iter() + .find(|candidate| candidate.id == model_id) + .map_or_else( + || { + if model_id == DEFAULT_MODEL_ID { + DEFAULT_MODEL_LABEL.to_owned() + } else { + model_id.to_owned() + } + }, + |candidate| candidate.display_name, + ), + } +} + +fn effort_level_label(value: &str) -> Option { + EffortLevel::from_stored(value).map(|level| level.label().to_owned()) +} + +fn project_root(app: &App) -> &std::path::Path { + std::path::Path::new(&app.cwd_raw) +} + +fn option_label(spec: &SettingSpec, value: &str) -> Option { + match spec.options { + SettingOptions::Static(options) => options + .iter() + .find(|option| option.stored == value) + .map(|option| option.label.to_owned()), + SettingOptions::RuntimeCatalog(RuntimeCatalogKind::PermissionModes) => { + DEFAULT_PERMISSION_OPTIONS + .iter() + .find(|option| option.stored == value) + .map(|option| option.label.to_owned()) + } + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/claude-code-rust/src/app/config/resolve.rs b/claude-code-rust/src/app/config/resolve.rs new file mode 100644 index 0000000..ba992a3 --- /dev/null +++ b/claude-code-rust/src/app/config/resolve.rs @@ -0,0 +1,164 @@ +use super::{ + DEFAULT_MODEL_ID, DEFAULT_PERMISSION_OPTIONS, DefaultPermissionMode, LANGUAGE_MAX_CHARS, + LANGUAGE_MIN_CHARS, OutputStyle, PreferredNotifChannel, ResolvedChoice, ResolvedSetting, + ResolvedSettingValue, RuntimeCatalogKind, SettingId, SettingOptions, SettingSpec, + SettingValidation, store, +}; +use crate::agent::model::AvailableModel; +use serde_json::Value; + +pub(super) fn resolve_setting_document( + document: &Value, + setting_id: SettingId, + available_models: &[AvailableModel], +) -> ResolvedSetting { + let spec = super::setting_spec(setting_id); + match setting_id { + SettingId::AlwaysThinking | SettingId::FastMode | SettingId::ReduceMotion => { + resolve_bool_setting(document, spec, false) + } + SettingId::DefaultPermissionMode => { + resolve_string_setting(document, spec, DefaultPermissionMode::Default.as_stored()) + } + SettingId::Language => resolve_language_setting(document, spec), + SettingId::ShowTips | SettingId::RespectGitignore | SettingId::TerminalProgressBar => { + resolve_bool_setting(document, spec, true) + } + SettingId::Model => resolve_model_setting(document, spec, available_models), + SettingId::OutputStyle => { + resolve_string_setting(document, spec, OutputStyle::Default.as_stored()) + } + SettingId::ThinkingEffort => resolve_string_setting(document, spec, "medium"), + SettingId::Theme => resolve_string_setting(document, spec, "dark"), + SettingId::Notifications => { + resolve_string_setting(document, spec, PreferredNotifChannel::default().as_stored()) + } + SettingId::EditorMode => resolve_string_setting(document, spec, "default"), + } +} + +fn resolve_bool_setting(document: &Value, spec: &SettingSpec, fallback: bool) -> ResolvedSetting { + match store::read_persisted_setting(document, spec) { + Ok(store::PersistedSettingValue::Bool(value)) => ResolvedSetting { + value: ResolvedSettingValue::Bool(value), + validation: SettingValidation::Valid, + }, + Ok(store::PersistedSettingValue::Missing) => ResolvedSetting { + value: ResolvedSettingValue::Bool(fallback), + validation: SettingValidation::Valid, + }, + Ok(store::PersistedSettingValue::String(_)) | Err(()) => ResolvedSetting { + value: ResolvedSettingValue::Bool(fallback), + validation: SettingValidation::InvalidValue, + }, + } +} + +fn resolve_string_setting( + document: &Value, + spec: &SettingSpec, + fallback: &'static str, +) -> ResolvedSetting { + match store::read_persisted_setting(document, spec) { + Ok(store::PersistedSettingValue::String(value)) if option_exists(spec, &value) => { + ResolvedSetting { + value: ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)), + validation: SettingValidation::Valid, + } + } + Ok(store::PersistedSettingValue::Missing) => ResolvedSetting { + value: ResolvedSettingValue::Choice(ResolvedChoice::Stored(fallback.to_owned())), + validation: SettingValidation::Valid, + }, + Ok(store::PersistedSettingValue::String(_) | store::PersistedSettingValue::Bool(_)) + | Err(()) => ResolvedSetting { + value: ResolvedSettingValue::Choice(ResolvedChoice::Stored(fallback.to_owned())), + validation: SettingValidation::InvalidValue, + }, + } +} + +fn resolve_language_setting(document: &Value, spec: &SettingSpec) -> ResolvedSetting { + match store::read_persisted_setting(document, spec) { + Ok(store::PersistedSettingValue::String(value)) => normalized_language_value(&value) + .filter(|normalized| language_input_validation_message(normalized).is_none()) + .map_or( + ResolvedSetting { + value: ResolvedSettingValue::Text(String::new()), + validation: SettingValidation::InvalidValue, + }, + |normalized| ResolvedSetting { + value: ResolvedSettingValue::Text(normalized), + validation: SettingValidation::Valid, + }, + ), + Ok(store::PersistedSettingValue::Missing) => ResolvedSetting { + value: ResolvedSettingValue::Text(String::new()), + validation: SettingValidation::Valid, + }, + Ok(store::PersistedSettingValue::Bool(_)) | Err(()) => ResolvedSetting { + value: ResolvedSettingValue::Text(String::new()), + validation: SettingValidation::InvalidValue, + }, + } +} + +fn resolve_model_setting( + document: &Value, + spec: &SettingSpec, + available_models: &[AvailableModel], +) -> ResolvedSetting { + match store::read_persisted_setting(document, spec) { + Ok(store::PersistedSettingValue::Missing) => ResolvedSetting { + value: ResolvedSettingValue::Choice(ResolvedChoice::Automatic), + validation: SettingValidation::Valid, + }, + Ok(store::PersistedSettingValue::String(value)) + if available_models.is_empty() + || value == DEFAULT_MODEL_ID + || available_models.iter().any(|model| model.id == value) => + { + ResolvedSetting { + value: ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)), + validation: SettingValidation::Valid, + } + } + Ok(store::PersistedSettingValue::String(_)) => ResolvedSetting { + value: ResolvedSettingValue::Choice(ResolvedChoice::Automatic), + validation: SettingValidation::UnavailableOption, + }, + Ok(store::PersistedSettingValue::Bool(_)) | Err(()) => ResolvedSetting { + value: ResolvedSettingValue::Choice(ResolvedChoice::Automatic), + validation: SettingValidation::InvalidValue, + }, + } +} + +fn option_exists(spec: &SettingSpec, value: &str) -> bool { + match spec.options { + SettingOptions::Static(options) => options.iter().any(|option| option.stored == value), + SettingOptions::RuntimeCatalog(RuntimeCatalogKind::PermissionModes) => { + DEFAULT_PERMISSION_OPTIONS.iter().any(|option| option.stored == value) + } + SettingOptions::RuntimeCatalog(RuntimeCatalogKind::Models) => value == DEFAULT_MODEL_ID, + SettingOptions::None => false, + } +} + +#[must_use] +pub(crate) fn language_input_validation_message(value: &str) -> Option<&'static str> { + let value = normalized_language_value(value)?; + let length = value.chars().count(); + if length < LANGUAGE_MIN_CHARS { + Some("Language must be at least 2 characters.") + } else if length > LANGUAGE_MAX_CHARS { + Some("Language must be at most 30 characters.") + } else { + None + } +} + +pub(super) fn normalized_language_value(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} diff --git a/claude-code-rust/src/app/config/store.rs b/claude-code-rust/src/app/config/store.rs new file mode 100644 index 0000000..9f6096e --- /dev/null +++ b/claude-code-rust/src/app/config/store.rs @@ -0,0 +1,810 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Map, Value}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{ + DefaultPermissionMode, OutputStyle, PreferredNotifChannel, SettingId, SettingKind, SettingSpec, + setting_spec, +}; +use crate::agent::model::EffortLevel; + +const SETTINGS_FILENAME: &str = "settings.json"; +const LOCAL_SETTINGS_FILENAME: &str = "settings.local.json"; +const PREFERENCES_FILENAME: &str = ".claude.json"; +const CLAUDE_DIR: &str = ".claude"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PersistedSettingValue { + Missing, + Bool(bool), + String(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsPaths { + pub settings: PathBuf, + pub local_settings: PathBuf, + pub preferences: PathBuf, +} + +pub struct LoadedSettingsDocuments { + pub paths: SettingsPaths, + pub settings_document: Value, + pub local_settings_document: Value, + pub preferences_document: Value, + pub notice: Option, +} + +pub fn load( + home_override: Option<&Path>, + project_root_override: Option<&Path>, +) -> Result { + let paths = resolve_paths(home_override, project_root_override)?; + let (settings_document, settings_notice) = load_document(&paths.settings)?; + let (local_settings_document, local_settings_notice) = load_document(&paths.local_settings)?; + let (preferences_document, preferences_notice) = load_document(&paths.preferences)?; + + let notices = [settings_notice, local_settings_notice, preferences_notice] + .into_iter() + .flatten() + .collect::>(); + let notice = (!notices.is_empty()).then(|| notices.join(" ")); + + Ok(LoadedSettingsDocuments { + paths, + settings_document, + local_settings_document, + preferences_document, + notice, + }) +} + +pub fn save(path: &Path, document: &Value) -> Result<(), String> { + let parent = path.parent().ok_or_else(|| "Settings path has no parent directory".to_owned())?; + std::fs::create_dir_all(parent) + .map_err(|err| format!("Failed to create settings directory: {err}"))?; + + let normalized = normalized_root(document); + let temp_path = unique_temp_path(parent, path.file_name().and_then(std::ffi::OsStr::to_str)); + let mut temp = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .map_err(|err| format!("Failed to create settings temp file: {err}"))?; + serde_json::to_writer_pretty(&mut temp, &normalized) + .map_err(|err| format!("Failed to serialize settings: {err}"))?; + temp.write_all(b"\n").map_err(|err| format!("Failed to finalize settings file: {err}"))?; + temp.flush().map_err(|err| format!("Failed to flush settings file: {err}"))?; + temp.sync_all().map_err(|err| format!("Failed to sync settings file: {err}"))?; + drop(temp); + std::fs::rename(&temp_path, path) + .map_err(|err| format!("Failed to move settings file into place: {err}"))?; + Ok(()) +} + +pub fn read_persisted_setting( + document: &Value, + spec: &SettingSpec, +) -> Result { + let Some(value) = read_json_path(document, spec.json_path) else { + return Ok(PersistedSettingValue::Missing); + }; + + match spec.kind { + SettingKind::Bool => match value { + Value::Bool(flag) => Ok(PersistedSettingValue::Bool(*flag)), + _ => Err(()), + }, + SettingKind::Enum | SettingKind::DynamicEnum | SettingKind::Text => match value { + Value::String(text) => Ok(PersistedSettingValue::String(text.clone())), + _ => Err(()), + }, + } +} + +pub fn write_persisted_setting( + document: &mut Value, + spec: &SettingSpec, + value: PersistedSettingValue, +) { + match value { + PersistedSettingValue::Missing => remove_json_path(document, spec.json_path), + PersistedSettingValue::Bool(flag) => { + set_json_path(document, spec.json_path, Value::Bool(flag)); + } + PersistedSettingValue::String(text) => { + set_json_path(document, spec.json_path, Value::String(text)); + } + } +} + +pub fn fast_mode(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::FastMode))? { + PersistedSettingValue::Missing => Ok(false), + PersistedSettingValue::Bool(value) => Ok(value), + PersistedSettingValue::String(_) => Err(()), + } +} + +pub fn set_fast_mode(document: &mut Value, enabled: bool) { + write_persisted_setting( + document, + setting_spec(SettingId::FastMode), + PersistedSettingValue::Bool(enabled), + ); +} + +pub fn always_thinking_enabled(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::AlwaysThinking))? { + PersistedSettingValue::Missing => Ok(false), + PersistedSettingValue::Bool(value) => Ok(value), + PersistedSettingValue::String(_) => Err(()), + } +} + +pub fn set_always_thinking_enabled(document: &mut Value, enabled: bool) { + write_persisted_setting( + document, + setting_spec(SettingId::AlwaysThinking), + PersistedSettingValue::Bool(enabled), + ); +} + +pub fn thinking_effort_level(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::ThinkingEffort))? { + PersistedSettingValue::Missing => Ok(EffortLevel::Medium), + PersistedSettingValue::Bool(_) => Err(()), + PersistedSettingValue::String(value) => EffortLevel::from_stored(&value).ok_or(()), + } +} + +pub fn set_thinking_effort_level(document: &mut Value, level: EffortLevel) { + write_persisted_setting( + document, + setting_spec(SettingId::ThinkingEffort), + PersistedSettingValue::String(level.as_stored().to_owned()), + ); +} + +pub fn spinner_tips_enabled(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::ShowTips))? { + PersistedSettingValue::Missing => Ok(true), + PersistedSettingValue::Bool(value) => Ok(value), + PersistedSettingValue::String(_) => Err(()), + } +} + +pub fn set_spinner_tips_enabled(document: &mut Value, enabled: bool) { + write_persisted_setting( + document, + setting_spec(SettingId::ShowTips), + PersistedSettingValue::Bool(enabled), + ); +} + +pub fn terminal_progress_bar_enabled(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::TerminalProgressBar))? { + PersistedSettingValue::Missing => Ok(true), + PersistedSettingValue::Bool(value) => Ok(value), + PersistedSettingValue::String(_) => Err(()), + } +} + +pub fn set_terminal_progress_bar_enabled(document: &mut Value, enabled: bool) { + write_persisted_setting( + document, + setting_spec(SettingId::TerminalProgressBar), + PersistedSettingValue::Bool(enabled), + ); +} + +pub fn prefers_reduced_motion(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::ReduceMotion))? { + PersistedSettingValue::Missing => Ok(false), + PersistedSettingValue::Bool(value) => Ok(value), + PersistedSettingValue::String(_) => Err(()), + } +} + +pub fn set_prefers_reduced_motion(document: &mut Value, enabled: bool) { + write_persisted_setting( + document, + setting_spec(SettingId::ReduceMotion), + PersistedSettingValue::Bool(enabled), + ); +} + +pub fn output_style(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::OutputStyle))? { + PersistedSettingValue::Missing => Ok(OutputStyle::Default), + PersistedSettingValue::Bool(_) => Err(()), + PersistedSettingValue::String(value) => OutputStyle::from_stored(&value).ok_or(()), + } +} + +pub fn set_output_style(document: &mut Value, style: OutputStyle) { + write_persisted_setting( + document, + setting_spec(SettingId::OutputStyle), + PersistedSettingValue::String(style.as_stored().to_owned()), + ); +} + +pub fn model(document: &Value) -> Result, ()> { + match read_persisted_setting(document, setting_spec(SettingId::Model))? { + PersistedSettingValue::Missing => Ok(None), + PersistedSettingValue::Bool(_) => Err(()), + PersistedSettingValue::String(value) => Ok(Some(value)), + } +} + +pub fn set_model(document: &mut Value, model: Option<&str>) { + let value = model.map_or(PersistedSettingValue::Missing, |model| { + PersistedSettingValue::String(model.to_owned()) + }); + write_persisted_setting(document, setting_spec(SettingId::Model), value); +} + +#[cfg(test)] +pub fn default_permission_mode(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::DefaultPermissionMode))? { + PersistedSettingValue::Missing => Ok(DefaultPermissionMode::Default), + PersistedSettingValue::Bool(_) => Err(()), + PersistedSettingValue::String(value) => { + DefaultPermissionMode::from_stored(&value).ok_or(()) + } + } +} + +pub fn set_default_permission_mode(document: &mut Value, mode: DefaultPermissionMode) { + write_persisted_setting( + document, + setting_spec(SettingId::DefaultPermissionMode), + PersistedSettingValue::String(mode.as_stored().to_owned()), + ); +} + +pub fn language(document: &Value) -> Result, ()> { + match read_persisted_setting(document, setting_spec(SettingId::Language))? { + PersistedSettingValue::Missing => Ok(None), + PersistedSettingValue::Bool(_) => Err(()), + PersistedSettingValue::String(value) => Ok(Some(value)), + } +} + +pub fn set_language(document: &mut Value, value: Option<&str>) { + let persisted = value + .map(str::trim) + .filter(|text| !text.is_empty()) + .map_or(PersistedSettingValue::Missing, |text| { + PersistedSettingValue::String(text.to_owned()) + }); + write_persisted_setting(document, setting_spec(SettingId::Language), persisted); +} + +pub fn respect_gitignore(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::RespectGitignore))? { + PersistedSettingValue::Missing => Ok(true), + PersistedSettingValue::Bool(value) => Ok(value), + PersistedSettingValue::String(_) => Err(()), + } +} + +pub fn set_respect_gitignore(document: &mut Value, enabled: bool) { + write_persisted_setting( + document, + setting_spec(SettingId::RespectGitignore), + PersistedSettingValue::Bool(enabled), + ); +} + +pub fn preferred_notification_channel(document: &Value) -> Result { + match read_persisted_setting(document, setting_spec(SettingId::Notifications))? { + PersistedSettingValue::Missing => Ok(PreferredNotifChannel::default()), + PersistedSettingValue::Bool(_) => Err(()), + PersistedSettingValue::String(value) => { + PreferredNotifChannel::from_stored(&value).ok_or(()) + } + } +} + +pub fn set_preferred_notification_channel(document: &mut Value, channel: PreferredNotifChannel) { + write_persisted_setting( + document, + setting_spec(SettingId::Notifications), + PersistedSettingValue::String(channel.as_stored().to_owned()), + ); +} + +fn resolve_paths( + home_override: Option<&Path>, + project_root_override: Option<&Path>, +) -> Result { + let home = if let Some(path) = home_override { + path.to_path_buf() + } else { + dirs::home_dir().ok_or_else(|| "Failed to resolve home directory".to_owned())? + }; + let project_root = if let Some(path) = project_root_override { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|err| format!("Failed to resolve current directory: {err}"))? + }; + + Ok(SettingsPaths { + settings: home.join(CLAUDE_DIR).join(SETTINGS_FILENAME), + local_settings: project_root.join(CLAUDE_DIR).join(LOCAL_SETTINGS_FILENAME), + preferences: home.join(PREFERENCES_FILENAME), + }) +} + +fn load_document(path: &Path) -> Result<(Value, Option), String> { + match std::fs::read_to_string(path) { + Ok(raw) => parse_document(path, &raw), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok((Value::Object(Map::new()), None)) + } + Err(err) => Err(format!("Failed to read settings file: {err}")), + } +} + +fn parse_document(path: &Path, raw: &str) -> Result<(Value, Option), String> { + if let Ok(Value::Object(object)) = serde_json::from_str::(raw) { + Ok((Value::Object(object), None)) + } else { + let backup = backup_malformed_file(path)?; + Ok(( + Value::Object(Map::new()), + Some(format!("Malformed settings file backed up to {}", backup.display())), + )) + } +} + +fn backup_malformed_file(path: &Path) -> Result { + let stamp = + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |duration| duration.as_secs()); + let backup = path.with_extension(format!("json.bak.{stamp}")); + std::fs::copy(path, &backup) + .map_err(|err| format!("Failed to back up malformed settings file: {err}"))?; + Ok(backup) +} + +fn unique_temp_path(parent: &Path, filename_hint: Option<&str>) -> PathBuf { + let stamp = + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |duration| duration.as_nanos()); + let filename = filename_hint.unwrap_or(SETTINGS_FILENAME); + parent.join(format!(".{filename}.{stamp}.tmp")) +} + +fn read_json_path<'a>(document: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = document; + for key in path { + current = current.as_object()?.get(*key)?; + } + Some(current) +} + +fn set_json_path(document: &mut Value, path: &[&str], value: Value) { + let Some((last_key, parents)) = path.split_last() else { + return; + }; + + let mut current = ensure_object_mut(document); + for key in parents { + let child = current.entry((*key).to_owned()).or_insert_with(|| Value::Object(Map::new())); + if !child.is_object() { + *child = Value::Object(Map::new()); + } + current = match child { + Value::Object(object) => object, + _ => unreachable!("child must be an object after normalization"), + }; + } + + current.insert((*last_key).to_owned(), value); +} + +fn remove_json_path(document: &mut Value, path: &[&str]) { + if let Value::Object(object) = document { + remove_from_object_path(object, path); + } +} + +fn remove_from_object_path(object: &mut Map, path: &[&str]) -> bool { + let Some((head, tail)) = path.split_first() else { + return object.is_empty(); + }; + + if tail.is_empty() { + object.remove(*head); + return object.is_empty(); + } + + let should_remove_child = if let Some(child) = object.get_mut(*head) { + match child { + Value::Object(child_object) => remove_from_object_path(child_object, tail), + _ => true, + } + } else { + false + }; + + if should_remove_child { + object.remove(*head); + } + + object.is_empty() +} + +fn normalized_root(document: &Value) -> Value { + match document { + Value::Object(object) => Value::Object(object.clone()), + _ => Value::Object(Map::new()), + } +} + +fn ensure_object_mut(document: &mut Value) -> &mut Map { + if !document.is_object() { + *document = Value::Object(Map::new()); + } + + match document { + Value::Object(object) => object, + _ => unreachable!("document must be an object after normalization"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::config::setting_spec; + + #[test] + fn load_missing_files_returns_empty_objects() { + let dir = tempfile::tempdir().expect("tempdir"); + + let loaded = load(Some(dir.path()), Some(dir.path())).expect("load"); + + assert_eq!(loaded.settings_document, Value::Object(Map::new())); + assert_eq!(loaded.local_settings_document, Value::Object(Map::new())); + assert_eq!(loaded.preferences_document, Value::Object(Map::new())); + assert!(loaded.notice.is_none()); + assert_eq!(loaded.paths.settings, dir.path().join(".claude").join("settings.json")); + assert_eq!( + loaded.paths.local_settings, + dir.path().join(".claude").join("settings.local.json") + ); + assert_eq!(loaded.paths.preferences, dir.path().join(".claude.json")); + } + + #[test] + fn load_malformed_preferences_file_creates_backup_and_preserves_settings() { + let dir = tempfile::tempdir().expect("tempdir"); + let settings_path = dir.path().join(".claude").join("settings.json"); + let preferences_path = dir.path().join(".claude.json"); + std::fs::create_dir_all(settings_path.parent().expect("settings parent")) + .expect("create settings dir"); + std::fs::write(&settings_path, r#"{"fastMode":true}"#).expect("write settings"); + std::fs::write(&preferences_path, "{ not-json").expect("write malformed"); + + let loaded = load(Some(dir.path()), Some(dir.path())).expect("load"); + + assert_eq!(fast_mode(&loaded.settings_document), Ok(true)); + assert_eq!(loaded.preferences_document, Value::Object(Map::new())); + let notice = loaded.notice.expect("backup notice"); + assert!(notice.contains("Malformed settings file backed up")); + let backups = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|candidate| candidate != &preferences_path) + .filter(|candidate| candidate.file_name().is_some_and(|name| name != ".claude")) + .collect::>(); + assert_eq!(backups.len(), 1); + } + + #[test] + fn save_preserves_unknown_keys_and_updates_fast_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + let mut document = serde_json::json!({ + "fastMode": false, + "unknown": { + "keep": true + } + }); + set_fast_mode(&mut document, true); + + save(&path, &document).expect("save"); + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"fastMode\": true")); + assert!(raw.contains("\"keep\": true")); + } + + #[test] + fn default_permission_mode_defaults_to_default() { + let document = Value::Object(Map::new()); + + assert_eq!(default_permission_mode(&document), Ok(DefaultPermissionMode::Default)); + } + + #[test] + fn respect_gitignore_defaults_to_true() { + let document = Value::Object(Map::new()); + + assert_eq!(respect_gitignore(&document), Ok(true)); + } + + #[test] + fn terminal_progress_bar_defaults_to_true() { + let document = Value::Object(Map::new()); + + assert_eq!(terminal_progress_bar_enabled(&document), Ok(true)); + } + + #[test] + fn output_style_defaults_to_default() { + let document = Value::Object(Map::new()); + + assert_eq!(output_style(&document), Ok(OutputStyle::Default)); + } + + #[test] + fn model_defaults_to_none() { + let document = Value::Object(Map::new()); + + assert_eq!(model(&document), Ok(None)); + } + + #[test] + fn language_defaults_to_none() { + let document = Value::Object(Map::new()); + + assert_eq!(language(&document), Ok(None)); + } + + #[test] + fn preferred_notification_channel_defaults_to_iterm2() { + let document = Value::Object(Map::new()); + + assert_eq!(preferred_notification_channel(&document), Ok(PreferredNotifChannel::Iterm2)); + } + + #[test] + fn preferred_notification_channel_rejects_invalid_stored_value() { + let document = serde_json::json!({ + "preferredNotifChannel": "not-a-channel" + }); + + assert_eq!(preferred_notification_channel(&document), Err(())); + } + + #[test] + fn output_style_rejects_invalid_stored_value() { + let document = serde_json::json!({ + "outputStyle": "Verbose" + }); + + assert_eq!(output_style(&document), Err(())); + } + + #[test] + fn respect_gitignore_rejects_invalid_stored_value() { + let document = serde_json::json!({ + "respectGitignore": "yes" + }); + + assert_eq!(respect_gitignore(&document), Err(())); + } + + #[test] + fn model_rejects_invalid_stored_value() { + let document = serde_json::json!({ + "model": true + }); + + assert_eq!(model(&document), Err(())); + } + + #[test] + fn language_rejects_invalid_stored_value() { + let document = serde_json::json!({ + "language": true + }); + + assert_eq!(language(&document), Err(())); + } + + #[test] + fn default_permission_mode_rejects_invalid_stored_value() { + let document = serde_json::json!({ + "permissions": { + "defaultMode": "not-a-mode" + } + }); + + assert_eq!(default_permission_mode(&document), Err(())); + } + + #[test] + fn save_preserves_unknown_keys_and_updates_default_permission_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + let mut document = serde_json::json!({ + "permissions": { + "defaultMode": "default", + "keep": true + }, + "unknown": { + "keep": true + } + }); + set_default_permission_mode(&mut document, DefaultPermissionMode::Plan); + + save(&path, &document).expect("save"); + let raw = std::fs::read_to_string(path).expect("read"); + + assert!(raw.contains("\"defaultMode\": \"plan\"")); + assert!(raw.contains("\"keep\": true")); + } + + #[test] + fn save_preserves_unknown_keys_and_updates_model() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + let mut document = serde_json::json!({ + "model": "old-model", + "unknown": { + "keep": true + } + }); + set_model(&mut document, Some("sonnet")); + + save(&path, &document).expect("save"); + let raw = std::fs::read_to_string(path).expect("read"); + + assert!(raw.contains("\"model\": \"sonnet\"")); + assert!(raw.contains("\"keep\": true")); + } + + #[test] + fn save_preserves_unknown_keys_and_updates_language() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + let mut document = serde_json::json!({ + "language": "English", + "unknown": { + "keep": true + } + }); + set_language(&mut document, Some("German")); + + save(&path, &document).expect("save"); + let raw = std::fs::read_to_string(path).expect("read"); + + assert!(raw.contains("\"language\": \"German\"")); + assert!(raw.contains("\"keep\": true")); + } + + #[test] + fn set_language_trims_and_removes_whitespace_only_values() { + let mut document = Value::Object(Map::new()); + set_language(&mut document, Some(" German ")); + assert_eq!(language(&document), Ok(Some("German".to_owned()))); + + set_language(&mut document, Some(" ")); + assert_eq!(language(&document), Ok(None)); + } + + #[test] + fn save_preserves_unknown_keys_and_updates_notifications() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude.json"); + let mut document = serde_json::json!({ + "preferredNotifChannel": "iterm2", + "theme": "dark" + }); + set_preferred_notification_channel(&mut document, PreferredNotifChannel::TerminalBell); + + save(&path, &document).expect("save"); + let raw = std::fs::read_to_string(path).expect("read"); + + assert!(raw.contains("\"preferredNotifChannel\": \"terminal_bell\"")); + assert!(raw.contains("\"theme\": \"dark\"")); + } + + #[test] + fn save_preserves_unknown_keys_and_updates_output_style() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.local.json"); + std::fs::create_dir_all(path.parent().expect("settings parent")).expect("create dir"); + let mut document = serde_json::json!({ + "outputStyle": "Default", + "spinnerTipsEnabled": true + }); + set_output_style(&mut document, OutputStyle::Learning); + + save(&path, &document).expect("save"); + let raw = std::fs::read_to_string(path).expect("read"); + + assert!(raw.contains("\"outputStyle\": \"Learning\"")); + assert!(raw.contains("\"spinnerTipsEnabled\": true")); + } + + #[test] + fn save_preserves_unknown_keys_and_updates_respect_gitignore() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude.json"); + let mut document = serde_json::json!({ + "respectGitignore": true, + "preferredNotifChannel": "iterm2" + }); + set_respect_gitignore(&mut document, false); + + save(&path, &document).expect("save"); + let raw = std::fs::read_to_string(path).expect("read"); + + assert!(raw.contains("\"respectGitignore\": false")); + assert!(raw.contains("\"preferredNotifChannel\": \"iterm2\"")); + } + + #[test] + fn save_preserves_unknown_keys_and_updates_terminal_progress_bar() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude.json"); + let mut document = serde_json::json!({ + "terminalProgressBarEnabled": true, + "preferredNotifChannel": "iterm2" + }); + set_terminal_progress_bar_enabled(&mut document, false); + + save(&path, &document).expect("save"); + let raw = std::fs::read_to_string(path).expect("read"); + + assert!(raw.contains("\"terminalProgressBarEnabled\": false")); + assert!(raw.contains("\"preferredNotifChannel\": \"iterm2\"")); + } + + #[test] + fn write_persisted_setting_removes_nested_path_and_prunes_empty_parent() { + let mut document = serde_json::json!({ + "permissions": { + "defaultMode": "plan" + }, + "keep": true + }); + + write_persisted_setting( + &mut document, + setting_spec(SettingId::DefaultPermissionMode), + PersistedSettingValue::Missing, + ); + + assert_eq!( + document, + serde_json::json!({ + "keep": true + }) + ); + } + + #[test] + fn read_persisted_setting_uses_json_path_metadata() { + let document = serde_json::json!({ + "permissions": { + "defaultMode": "plan" + } + }); + + let value = + read_persisted_setting(&document, setting_spec(SettingId::DefaultPermissionMode)); + + assert_eq!(value, Ok(PersistedSettingValue::String("plan".to_owned()))); + } +} diff --git a/claude-code-rust/src/app/config/tests.rs b/claude-code-rust/src/app/config/tests.rs new file mode 100644 index 0000000..202eab9 --- /dev/null +++ b/claude-code-rust/src/app/config/tests.rs @@ -0,0 +1,1464 @@ +use super::*; +use crate::agent::model::AvailableModel; +use crate::agent::wire::BridgeCommand; +use crate::app::AppStatus; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::rc::Rc; +use tempfile::TempDir; + +fn open_settings_test_app() -> (TempDir, App) { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join(".claude.json"), + format!( + "{{\n \"projects\": {{\n \"{}\": {{ \"hasTrustDialogAccepted\": true }}\n }}\n}}\n", + crate::app::trust::store::normalize_project_key(dir.path()) + ), + ) + .expect("write trust prefs"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + open(&mut app).expect("open"); + (dir, app) +} + +fn select_setting(app: &mut App, setting_id: SettingId) { + app.config.selected_setting_index = + setting_specs().iter().position(|spec| spec.id == setting_id).expect("setting row"); +} + +fn app_with_status_connection() +-> (App, tokio::sync::mpsc::UnboundedReceiver) { + let mut app = App::test_default(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some(crate::agent::model::SessionId::new("session-1")); + app.config.active_tab = ConfigTab::Status; + app.recent_sessions = vec![crate::app::RecentSessionInfo { + session_id: "session-1".to_owned(), + summary: "Existing session summary".to_owned(), + last_modified_ms: 0, + file_size_bytes: 0, + cwd: Some("/test".to_owned()), + git_branch: None, + custom_title: Some("Current custom title".to_owned()), + first_prompt: Some("First prompt".to_owned()), + }]; + (app, rx) +} + +#[test] +fn open_loads_document_and_switches_view() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + std::fs::create_dir_all(path.parent().expect("settings parent")).expect("create dir"); + std::fs::write(&path, r#"{"fastMode":true}"#).expect("write"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + + assert_eq!(app.active_view, ActiveView::Config); + assert!(matches!( + resolve_setting_document(&app.config.committed_settings_document, SettingId::FastMode, &[]) + .value, + ResolvedSettingValue::Bool(true) + )); + assert!(app.config.settings_path.is_some()); + assert!(app.config.local_settings_path.is_some()); + assert!(app.config.preferences_path.is_some()); +} + +#[test] +fn open_does_not_force_stop_active_turn() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + app.status = AppStatus::Running; + + open(&mut app).expect("open"); + + assert_eq!(app.active_view, ActiveView::Config); + assert!(matches!(app.status, AppStatus::Running)); + assert!(app.pending_cancel_origin.is_none()); +} + +#[test] +fn initialize_shared_state_reconciles_trust_from_preferences() { + let dir = tempfile::tempdir().expect("tempdir"); + let prefs_path = dir.path().join(".claude.json"); + std::fs::write(&prefs_path, r#"{"projects":{}}"#).expect("write prefs"); + + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().join("project").to_string_lossy().to_string(); + app.trust.status = crate::app::trust::TrustStatus::Trusted; + + initialize_shared_state(&mut app).expect("initialize"); + + assert_eq!(app.trust.status, crate::app::trust::TrustStatus::Untrusted); + assert_eq!( + app.trust.project_key, + crate::app::trust::store::normalize_project_key(std::path::Path::new(&app.cwd_raw)) + ); +} + +#[test] +fn activate_tab_clears_status_and_error_feedback() { + let (_dir, mut app) = open_settings_test_app(); + app.config.status_message = Some("saved".into()); + app.config.last_error = Some("failed".into()); + + activate_tab(&mut app, ConfigTab::Plugins); + + assert!(app.config.status_message.is_none()); + assert!(app.config.last_error.is_none()); +} + +#[test] +fn reopen_reload_picks_up_external_settings_changes() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + std::fs::create_dir_all(path.parent().expect("settings parent")).expect("create dir"); + std::fs::write(&path, r#"{"fastMode":false}"#).expect("write"); + std::fs::write( + dir.path().join(".claude.json"), + format!( + "{{\n \"projects\": {{\n \"{}\": {{ \"hasTrustDialogAccepted\": true }}\n }}\n}}\n", + crate::app::trust::store::normalize_project_key(dir.path()) + ), + ) + .expect("write trust prefs"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + assert!(!app.config.fast_mode_effective()); + + close(&mut app); + std::fs::write(&path, r#"{"fastMode":true}"#).expect("rewrite"); + + open(&mut app).expect("reopen"); + + assert!(app.config.fast_mode_effective()); +} + +#[test] +fn reopen_clears_stale_transient_feedback() { + let (_dir, mut app) = open_settings_test_app(); + app.config.status_message = Some("stale status".to_owned()); + app.config.last_error = Some("stale error".to_owned()); + + close(&mut app); + open(&mut app).expect("reopen"); + + assert!(app.config.status_message.is_none()); + assert!(app.config.last_error.is_none()); +} + +#[test] +fn space_persists_toggled_fast_mode_immediately() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::FastMode); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"fastMode\": true")); + assert!(app.config.last_error.is_none()); +} + +#[test] +fn handle_key_moves_between_config_rows() { + let mut app = App::test_default(); + app.active_view = ActiveView::Config; + let last_index = setting_specs().len().saturating_sub(1); + + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(app.config.selected_setting_index, 1); + + for _ in 0..setting_specs().len().saturating_add(4) { + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + + assert_eq!(app.config.selected_setting_index, last_index); +} + +#[test] +fn open_rejects_untrusted_projects() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + app.trust.status = crate::app::trust::TrustStatus::Untrusted; + + let err = open(&mut app).expect_err("open should be blocked"); + + assert!(err.contains("Project trust")); + assert_eq!(app.active_view, ActiveView::Chat); +} + +#[test] +fn tab_navigation_wraps_and_clears_status_message() { + let (_dir, mut app) = open_settings_test_app(); + app.config.status_message = Some("saved".to_owned()); + + handle_key(&mut app, KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + + assert_eq!(app.config.active_tab, ConfigTab::Mcp); + assert!(app.config.status_message.is_none()); + + handle_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(app.config.active_tab, ConfigTab::Mcp); +} + +#[test] +fn plugins_tab_uses_arrow_keys_for_inner_navigation() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.config.selected_setting_index = 3; + app.plugins.installed = vec![ + crate::app::plugins::InstalledPluginEntry { + id: "frontend-design@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "user".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: None, + capability: crate::app::plugins::PluginCapability::Skill, + }, + crate::app::plugins::InstalledPluginEntry { + id: "rust-analyzer-lsp@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "user".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: None, + capability: crate::app::plugins::PluginCapability::Skill, + }, + ]; + + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + + assert_eq!(app.config.selected_setting_index, 3); + assert_eq!(app.config.active_tab, ConfigTab::Plugins); + assert_eq!(app.plugins.installed_selected_index, 1); + assert_eq!(app.plugins.active_tab, crate::app::plugins::PluginsViewTab::Plugins); + assert_eq!(app.plugins.installed_search_query, ""); + assert_eq!(app.plugins.plugins_search_query, ""); + assert!(app.config.overlay.is_none()); +} + +#[test] +fn plugins_inner_tab_switch_does_not_trigger_refresh() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.loading = false; + app.plugins.last_inventory_refresh_at = None; + + handle_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + + assert_eq!(app.plugins.active_tab, crate::app::plugins::PluginsViewTab::Plugins); + assert!(!app.plugins.loading); +} + +#[test] +fn installed_plugin_enter_opens_actions_overlay() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.installed = vec![crate::app::plugins::InstalledPluginEntry { + id: "frontend-design@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "local".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: Some("C:\\work\\project-a".to_owned()), + capability: crate::app::plugins::PluginCapability::Skill, + }]; + app.plugins.marketplace = vec![crate::app::plugins::MarketplaceEntry { + plugin_id: "frontend-design@claude-plugins-official".to_owned(), + name: "frontend-design".to_owned(), + description: Some("Create distinctive interfaces".to_owned()), + marketplace_name: Some("claude-plugins-official".to_owned()), + version: Some("1.0.0".to_owned()), + install_count: Some(42), + source: None, + }]; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let overlay = app.config.installed_plugin_actions_overlay().expect("installed actions overlay"); + assert_eq!(overlay.title, "Frontend Design From Claude Plugins Official"); + assert_eq!(overlay.description, "Create distinctive interfaces"); + assert_eq!( + overlay.actions, + vec![ + InstalledPluginActionKind::Disable, + InstalledPluginActionKind::Update, + InstalledPluginActionKind::InstallInCurrentProject, + InstalledPluginActionKind::Uninstall, + ] + ); +} + +#[test] +fn installed_plugin_overlay_uses_up_down_and_escape() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.installed = vec![crate::app::plugins::InstalledPluginEntry { + id: "frontend-design@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "user".to_owned(), + enabled: false, + installed_at: None, + last_updated: None, + project_path: None, + capability: crate::app::plugins::PluginCapability::Skill, + }]; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + + assert_eq!( + app.config.installed_plugin_actions_overlay().map(|overlay| overlay.selected_index), + Some(1) + ); + + handle_key(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + + assert_eq!( + app.config.installed_plugin_actions_overlay().map(|overlay| overlay.selected_index), + Some(0) + ); + + handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(app.config.overlay.is_none()); +} + +#[test] +fn plugin_enter_opens_install_overlay() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Plugins; + app.plugins.marketplace = vec![crate::app::plugins::MarketplaceEntry { + plugin_id: "frontend-design@claude-plugins-official".to_owned(), + name: "frontend-design".to_owned(), + description: Some("Create distinctive interfaces".to_owned()), + marketplace_name: Some("claude-plugins-official".to_owned()), + version: Some("1.0.0".to_owned()), + install_count: Some(42), + source: None, + }]; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let overlay = app.config.plugin_install_overlay().expect("Plugin install overlay"); + assert_eq!(overlay.title, "Frontend Design"); + assert_eq!(overlay.description, "Create distinctive interfaces"); + assert_eq!( + overlay.actions, + vec![ + PluginInstallActionKind::User, + PluginInstallActionKind::Project, + PluginInstallActionKind::Local, + ] + ); +} + +#[test] +fn plugin_install_overlay_uses_up_down_and_escape() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Plugins; + app.plugins.marketplace = vec![crate::app::plugins::MarketplaceEntry { + plugin_id: "frontend-design@claude-plugins-official".to_owned(), + name: "frontend-design".to_owned(), + description: Some("Create distinctive interfaces".to_owned()), + marketplace_name: Some("claude-plugins-official".to_owned()), + version: Some("1.0.0".to_owned()), + install_count: Some(42), + source: None, + }]; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + + assert_eq!(app.config.plugin_install_overlay().map(|overlay| overlay.selected_index), Some(1)); + + handle_key(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + + assert_eq!(app.config.plugin_install_overlay().map(|overlay| overlay.selected_index), Some(0)); + + handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(app.config.overlay.is_none()); +} + +#[test] +fn marketplace_enter_opens_actions_overlay_for_configured_marketplace() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Marketplace; + app.plugins.marketplaces = vec![crate::app::plugins::MarketplaceSourceEntry { + name: "claude-plugins-official".to_owned(), + source: Some("github".to_owned()), + repo: Some("anthropics/claude-plugins-official".to_owned()), + }]; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let overlay = app.config.marketplace_actions_overlay().expect("marketplace actions overlay"); + assert_eq!(overlay.title, "Claude Plugins Official"); + assert!(overlay.description.contains("Source: github")); + assert!(overlay.description.contains("Repo: anthropics/claude-plugins-official")); + assert_eq!( + overlay.actions, + vec![ + crate::app::config::MarketplaceActionKind::Update, + crate::app::config::MarketplaceActionKind::Remove, + ] + ); +} + +#[test] +fn marketplace_add_row_opens_text_input_overlay() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Marketplace; + app.plugins.marketplaces = vec![crate::app::plugins::MarketplaceSourceEntry { + name: "claude-plugins-official".to_owned(), + source: Some("github".to_owned()), + repo: Some("anthropics/claude-plugins-official".to_owned()), + }]; + + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let overlay = app.config.add_marketplace_overlay().expect("add marketplace overlay"); + assert_eq!(overlay.draft, ""); + assert_eq!(overlay.cursor, 0); +} + +#[test] +fn add_marketplace_overlay_supports_editing_and_escape() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Marketplace; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + let overlay = app.config.add_marketplace_overlay().expect("add marketplace overlay"); + assert_eq!(overlay.draft, "on"); + assert_eq!(overlay.cursor, 1); + + handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(app.config.overlay.is_none()); +} + +#[test] +fn add_marketplace_overlay_accepts_paste() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Marketplace; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + crate::app::events::handle_terminal_event( + &mut app, + Event::Paste("anthropics/claude-plugins-official".into()), + ); + + let overlay = app.config.add_marketplace_overlay().expect("add marketplace overlay"); + assert_eq!(overlay.draft, "anthropics/claude-plugins-official"); +} + +#[test] +fn plugins_search_accepts_paste_when_focused() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Plugins; + app.plugins.search_focused = true; + + crate::app::events::handle_terminal_event( + &mut app, + Event::Paste("frontend-design\nsupabase".into()), + ); + + assert_eq!(app.plugins.plugins_search_query, "frontend-design supabase"); +} + +#[test] +fn left_and_right_adjust_selected_setting_without_switching_tabs() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::FastMode); + + handle_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + + assert_eq!(app.config.active_tab, ConfigTab::Settings); + assert!(app.config.fast_mode_effective()); + + handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + + assert_eq!(app.config.active_tab, ConfigTab::Settings); + assert!(!app.config.fast_mode_effective()); +} + +#[test] +fn status_tab_r_opens_session_rename_overlay() { + let (mut app, _rx) = app_with_status_connection(); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + + assert_eq!( + app.config.session_rename_overlay().map(|overlay| overlay.draft.as_str()), + Some("Current custom title") + ); + assert_eq!(app.config.session_rename_overlay().map(|overlay| overlay.cursor), Some(20)); +} + +#[tokio::test(flavor = "current_thread")] +async fn activating_usage_tab_starts_refresh_lifecycle() { + tokio::task::LocalSet::new() + .run_until(async { + let (_dir, mut app) = open_settings_test_app(); + + activate_tab(&mut app, ConfigTab::Usage); + + assert_eq!(app.config.active_tab, ConfigTab::Usage); + assert!(app.usage.in_flight); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn usage_tab_r_triggers_manual_refresh() { + tokio::task::LocalSet::new() + .run_until(async { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Usage; + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + + assert!(app.usage.in_flight); + }) + .await; +} + +#[test] +fn status_tab_rename_confirm_sends_bridge_command() { + let (mut app, mut rx) = app_with_status_connection(); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + for _ in 0.."Current custom title".chars().count() { + handle_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + for ch in "Renamed session".chars() { + handle_key(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let envelope = rx.try_recv().expect("rename command"); + assert_eq!( + envelope.command, + BridgeCommand::RenameSession { + session_id: "session-1".to_owned(), + title: "Renamed session".to_owned(), + } + ); + assert!(app.config.overlay.is_none()); + assert_eq!(app.config.status_message.as_deref(), Some("Renaming session...")); + assert!(app.config.last_error.is_none()); + assert!(matches!( + app.config.pending_session_title_change.as_ref(), + Some(pending) + if pending.session_id == "session-1" + && matches!( + pending.kind, + PendingSessionTitleChangeKind::Rename { + requested_title: Some(ref requested_title) + } if requested_title == "Renamed session" + ) + )); +} + +#[test] +fn status_tab_rename_empty_confirm_clears_custom_title() { + let (mut app, mut rx) = app_with_status_connection(); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + for _ in 0.."Current custom title".chars().count() { + handle_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let envelope = rx.try_recv().expect("rename command"); + assert_eq!( + envelope.command, + BridgeCommand::RenameSession { session_id: "session-1".to_owned(), title: String::new() } + ); + assert_eq!(app.config.status_message.as_deref(), Some("Clearing session name...")); + assert!(matches!( + app.config.pending_session_title_change.as_ref(), + Some(pending) + if matches!( + pending.kind, + PendingSessionTitleChangeKind::Rename { requested_title: None } + ) + )); +} + +#[test] +fn status_tab_rename_escape_cancels_without_command() { + let (mut app, mut rx) = app_with_status_connection(); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(app.config.overlay.is_none()); + assert!(rx.try_recv().is_err()); + assert!(app.config.pending_session_title_change.is_none()); +} + +#[test] +fn status_tab_g_generates_session_title_from_current_title_fallback() { + let (mut app, mut rx) = app_with_status_connection(); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + + let envelope = rx.try_recv().expect("generate command"); + assert_eq!( + envelope.command, + BridgeCommand::GenerateSessionTitle { + session_id: "session-1".to_owned(), + description: "Current custom title".to_owned(), + } + ); + assert_eq!(app.config.status_message.as_deref(), Some("Generating session title...")); + assert!(matches!( + app.config.pending_session_title_change.as_ref(), + Some(pending) + if pending.session_id == "session-1" + && matches!(pending.kind, PendingSessionTitleChangeKind::Generate) + )); +} + +#[test] +fn status_tab_g_requires_existing_session_metadata() { + let (mut app, mut rx) = app_with_status_connection(); + app.recent_sessions[0].custom_title = None; + app.recent_sessions[0].summary.clear(); + app.recent_sessions[0].first_prompt = None; + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + + assert!(rx.try_recv().is_err()); + assert_eq!( + app.config.last_error.as_deref(), + Some("No session summary is available to generate a title") + ); + assert!(app.config.pending_session_title_change.is_none()); +} + +#[test] +fn overlay_enter_confirms_without_closing_config_screen() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::OutputStyle); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(app.active_view, ActiveView::Config); + assert!(app.config.overlay.is_none()); + assert_eq!( + store::output_style(&app.config.committed_local_settings_document), + Ok(OutputStyle::Explanatory) + ); +} + +#[test] +fn always_thinking_toggles_in_settings_document() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::AlwaysThinking); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!(store::always_thinking_enabled(&app.config.committed_settings_document), Ok(true)); +} + +#[test] +fn reduce_motion_toggles_in_local_settings_document() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::ReduceMotion); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!( + store::prefers_reduced_motion(&app.config.committed_local_settings_document), + Ok(true) + ); +} + +#[test] +fn show_tips_toggles_in_local_settings_document() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::ShowTips); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!( + store::spinner_tips_enabled(&app.config.committed_local_settings_document), + Ok(false) + ); +} + +#[test] +fn handle_key_cycles_default_permission_mode() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::DefaultPermissionMode); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!( + store::default_permission_mode(&app.config.committed_settings_document), + Ok(DefaultPermissionMode::AcceptEdits) + ); +} + +#[test] +fn respect_gitignore_toggles_in_preferences_document() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::RespectGitignore); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!(store::respect_gitignore(&app.config.committed_preferences_document), Ok(false)); +} + +#[test] +fn terminal_progress_bar_toggles_in_preferences_document() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::TerminalProgressBar); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!( + store::terminal_progress_bar_enabled(&app.config.committed_preferences_document), + Ok(false) + ); +} + +#[test] +fn immediate_save_respect_gitignore_invalidates_active_mention_session_cache() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + app.mention = Some(crate::app::mention::MentionState::new(0, 0, "rs".to_owned(), vec![])); + select_setting(&mut app, SettingId::RespectGitignore); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + let mention = app.mention.as_ref().expect("mention should stay active"); + assert!(mention.candidates.is_empty()); + assert_eq!(mention.placeholder_message().as_deref(), Some("Searching files...")); + assert!(!app.config.respect_gitignore_effective()); +} + +#[test] +fn save_preserves_invalid_unedited_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + std::fs::create_dir_all(path.parent().expect("settings parent")).expect("create dir"); + std::fs::write(&path, r#"{"permissions":{"defaultMode":"broken"},"fastMode":false}"#) + .expect("write"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::FastMode); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"defaultMode\": \"broken\"")); + assert!(raw.contains("\"fastMode\": true")); +} + +#[test] +fn resolved_model_uses_runtime_fallback_when_catalog_rejects_value() { + let mut app = App::test_default(); + app.available_models = vec![AvailableModel::new("sonnet", "Claude Sonnet")]; + store::set_model(&mut app.config.committed_settings_document, Some("unknown")); + + let resolved = resolved_setting(&app, setting_spec(SettingId::Model)); + + assert_eq!(resolved.validation, SettingValidation::UnavailableOption); + assert_eq!(setting_display_value(&app, setting_spec(SettingId::Model), &resolved), "Default"); +} + +#[test] +fn model_overlay_options_are_sorted_alphabetically() { + let mut app = App::test_default(); + app.available_models = vec![ + AvailableModel::new("sonnet", "Sonnet"), + AvailableModel::new("haiku", "Haiku"), + AvailableModel::new("opus", "Opus"), + ]; + + let labels = model_overlay_options(&app) + .into_iter() + .map(|option| option.display_name) + .collect::>(); + + assert_eq!(labels, vec!["Default", "Haiku", "Opus", "Sonnet"]); +} + +#[test] +fn notifications_cycle_in_preferences_document() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::Notifications); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!( + store::preferred_notification_channel(&app.config.committed_preferences_document), + Ok(PreferredNotifChannel::Iterm2WithBell) + ); +} + +#[test] +fn theme_cycles_in_preferences_document() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::Theme); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + let stored = store::read_persisted_setting( + &app.config.committed_preferences_document, + setting_spec(SettingId::Theme), + ); + assert_eq!(stored, Ok(store::PersistedSettingValue::String("light".to_owned()))); +} + +#[test] +fn editor_mode_cycles_in_preferences_document() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::EditorMode); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + let stored = store::read_persisted_setting( + &app.config.committed_preferences_document, + setting_spec(SettingId::EditorMode), + ); + assert_eq!(stored, Ok(store::PersistedSettingValue::String("vim".to_owned()))); +} + +#[test] +fn output_style_resolves_existing_project_value() { + let mut app = App::test_default(); + store::set_output_style( + &mut app.config.committed_local_settings_document, + OutputStyle::Explanatory, + ); + + let resolved = resolved_setting(&app, setting_spec(SettingId::OutputStyle)); + + assert_eq!(resolved.validation, SettingValidation::Valid); + assert_eq!( + setting_display_value(&app, setting_spec(SettingId::OutputStyle), &resolved), + "Explanatory" + ); +} + +#[test] +fn output_style_missing_value_falls_back_to_default() { + let app = App::test_default(); + + let resolved = resolved_setting(&app, setting_spec(SettingId::OutputStyle)); + + assert_eq!(resolved.validation, SettingValidation::Valid); + assert_eq!( + setting_display_value(&app, setting_spec(SettingId::OutputStyle), &resolved), + "Default" + ); +} + +#[test] +fn output_style_invalid_value_uses_default_with_invalid_state() { + let mut app = App::test_default(); + app.config.committed_local_settings_document = serde_json::json!({ "outputStyle": "Verbose" }); + + let resolved = resolved_setting(&app, setting_spec(SettingId::OutputStyle)); + + assert_eq!(resolved.validation, SettingValidation::InvalidValue); + assert_eq!( + setting_display_value(&app, setting_spec(SettingId::OutputStyle), &resolved), + "Default" + ); +} + +#[test] +fn language_resolves_existing_project_value() { + let mut app = App::test_default(); + store::set_language(&mut app.config.committed_settings_document, Some("German")); + + let resolved = resolved_setting(&app, setting_spec(SettingId::Language)); + + assert_eq!(resolved.validation, SettingValidation::Valid); + assert_eq!(setting_display_value(&app, setting_spec(SettingId::Language), &resolved), "German"); +} + +#[test] +fn language_missing_value_displays_not_set() { + let app = App::test_default(); + + let resolved = resolved_setting(&app, setting_spec(SettingId::Language)); + + assert_eq!(resolved.validation, SettingValidation::Valid); + assert_eq!( + setting_display_value(&app, setting_spec(SettingId::Language), &resolved), + "Not set" + ); +} + +#[test] +fn language_invalid_persisted_length_uses_not_set_with_invalid_state() { + let mut app = App::test_default(); + app.config.committed_settings_document = serde_json::json!({ "language": "E" }); + + let resolved = resolved_setting(&app, setting_spec(SettingId::Language)); + + assert_eq!(resolved.validation, SettingValidation::InvalidValue); + assert_eq!( + setting_display_value(&app, setting_spec(SettingId::Language), &resolved), + "Not set" + ); +} + +#[test] +fn language_whitespace_only_persisted_value_uses_not_set_with_invalid_state() { + let mut app = App::test_default(); + app.config.committed_settings_document = serde_json::json!({ "language": " " }); + + let resolved = resolved_setting(&app, setting_spec(SettingId::Language)); + + assert_eq!(resolved.validation, SettingValidation::InvalidValue); + assert_eq!( + setting_display_value(&app, setting_spec(SettingId::Language), &resolved), + "Not set" + ); +} + +#[test] +fn space_opens_output_style_overlay() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::OutputStyle); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!( + app.config.output_style_overlay().map(|overlay| overlay.selected), + Some(OutputStyle::Default) + ); +} + +#[test] +fn space_opens_language_overlay_with_existing_value() { + let (_dir, mut app) = open_settings_test_app(); + store::set_language(&mut app.config.committed_settings_document, Some("German")); + select_setting(&mut app, SettingId::Language); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!(app.config.language_overlay().map(|overlay| overlay.draft.as_str()), Some("German")); + assert_eq!(app.config.language_overlay().map(|overlay| overlay.cursor), Some(6)); +} + +#[test] +fn space_persists_local_project_settings_immediately() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.local.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::ReduceMotion); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + select_setting(&mut app, SettingId::ShowTips); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"prefersReducedMotion\": true")); + assert!(raw.contains("\"spinnerTipsEnabled\": false")); +} + +#[test] +fn output_style_overlay_confirm_persists_local_setting() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.local.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::OutputStyle); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"outputStyle\": \"Learning\"")); + assert_eq!( + store::output_style(&app.config.committed_local_settings_document), + Ok(OutputStyle::Learning) + ); + assert!(app.config.overlay.is_none()); +} + +#[test] +fn output_style_overlay_escape_cancels_without_persisting() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.local.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::OutputStyle); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!path.exists()); + assert!(app.config.overlay.is_none()); + assert_eq!( + store::output_style(&app.config.committed_local_settings_document), + Ok(OutputStyle::Default) + ); +} + +#[test] +fn language_overlay_confirm_persists_project_setting() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::Language); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + for ch in "German".chars() { + handle_key(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"language\": \"German\"")); + assert_eq!( + store::language(&app.config.committed_settings_document), + Ok(Some("German".to_owned())) + ); + assert!(app.config.overlay.is_none()); +} + +#[test] +fn language_overlay_confirm_trims_project_setting() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::Language); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + for ch in "German".chars() { + handle_key(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"language\": \"German\"")); + assert_eq!( + store::language(&app.config.committed_settings_document), + Ok(Some("German".to_owned())) + ); + assert!(app.config.overlay.is_none()); +} + +#[test] +fn language_overlay_empty_confirm_clears_existing_setting() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + std::fs::create_dir_all(path.parent().expect("settings parent")).expect("create dir"); + std::fs::write(&path, r#"{"language":"German"}"#).expect("write"); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::Language); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + for _ in 0..6 { + handle_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(!raw.contains("\"language\"")); + assert_eq!(store::language(&app.config.committed_settings_document), Ok(None)); + assert!(app.config.overlay.is_none()); +} + +#[test] +fn language_overlay_escape_cancels_without_persisting() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::Language); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!path.exists()); + assert!(app.config.overlay.is_none()); + assert_eq!(store::language(&app.config.committed_settings_document), Ok(None)); +} + +#[test] +fn language_overlay_blocks_too_short_input() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::Language); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('E'), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(app.config.language_overlay().is_some()); + assert!(!path.exists()); + assert_eq!(store::language(&app.config.committed_settings_document), Ok(None)); +} + +#[test] +fn language_overlay_blocks_too_long_input() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::Language); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + for ch in "abcdefghijklmnopqrstuvwxyzabcde".chars() { + handle_key(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(app.config.language_overlay().is_some()); + assert!(!path.exists()); + assert_eq!(store::language(&app.config.committed_settings_document), Ok(None)); +} + +#[test] +fn language_overlay_supports_cursor_aware_editing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + std::fs::create_dir_all(path.parent().expect("settings parent")).expect("create dir"); + std::fs::write(&path, r#"{"language":"German"}"#).expect("write"); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::Language); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + store::language(&app.config.committed_settings_document), + Ok(Some("Gerian".to_owned())) + ); +} + +#[test] +fn space_persists_always_thinking_in_user_settings() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::AlwaysThinking); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"alwaysThinkingEnabled\": true")); +} + +#[test] +fn space_persists_terminal_progress_bar_in_preferences() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude.json"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + select_setting(&mut app, SettingId::TerminalProgressBar); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"terminalProgressBarEnabled\": false")); +} + +#[test] +fn enter_closes_settings_without_editing_selected_row() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::FastMode); + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(app.active_view, ActiveView::Chat); + assert!(!app.config.fast_mode_effective()); +} + +#[test] +fn mcp_enter_opens_details_overlay_instead_of_closing_config() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Mcp; + app.session_id = Some(crate::agent::model::SessionId::new("session-1")); + app.mcp.servers = vec![crate::agent::types::McpServerStatus { + name: "filesystem".to_owned(), + status: crate::agent::types::McpServerConnectionStatus::Connected, + server_info: None, + error: None, + config: Some(crate::agent::types::McpServerStatusConfig::Stdio { + command: "npx".to_owned(), + args: vec!["@modelcontextprotocol/server-filesystem".to_owned()], + env: BTreeMap::new(), + }), + scope: Some("project".to_owned()), + tools: vec![], + }]; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(app.active_view, ActiveView::Config); + assert_eq!( + app.config.mcp_details_overlay().map(|overlay| overlay.server_name.as_str()), + Some("filesystem") + ); +} + +#[test] +fn mcp_details_overlay_enter_closes_overlay() { + let (_dir, mut app) = open_settings_test_app(); + app.config.active_tab = ConfigTab::Mcp; + app.config.overlay = Some(ConfigOverlayState::McpDetails(McpDetailsOverlayState { + server_name: "filesystem".to_owned(), + selected_index: 0, + })); + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(app.config.overlay.is_none()); + assert_eq!(app.active_view, ActiveView::Config); +} + +#[test] +fn mcp_tab_refresh_key_requests_snapshot() { + let (_dir, mut app) = open_settings_test_app(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some(crate::agent::model::SessionId::new("session-1")); + app.config.active_tab = ConfigTab::Mcp; + app.mcp.servers.push(crate::agent::types::McpServerStatus { + name: "stale".to_owned(), + status: crate::agent::types::McpServerConnectionStatus::NeedsAuth, + server_info: None, + error: None, + config: None, + scope: None, + tools: Vec::new(), + }); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + + let envelope = rx.try_recv().expect("mcp snapshot command"); + assert_eq!( + envelope.command, + BridgeCommand::GetMcpSnapshot { session_id: "session-1".to_owned() } + ); + assert!(app.mcp.in_flight); + assert!(app.mcp.servers.is_empty()); +} + +#[test] +fn request_mcp_snapshot_sends_outside_mcp_tab() { + let (_dir, mut app) = open_settings_test_app(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some(crate::agent::model::SessionId::new("session-1")); + app.config.active_tab = ConfigTab::Status; + + super::mcp::request_mcp_snapshot(&mut app); + + let envelope = rx.try_recv().expect("mcp snapshot command"); + assert_eq!( + envelope.command, + BridgeCommand::GetMcpSnapshot { session_id: "session-1".to_owned() } + ); + assert!(app.mcp.in_flight); +} + +#[test] +fn refresh_mcp_snapshot_clears_existing_servers_before_request() { + let (_dir, mut app) = open_settings_test_app(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some(crate::agent::model::SessionId::new("session-1")); + app.mcp.servers.push(crate::agent::types::McpServerStatus { + name: "stale".to_owned(), + status: crate::agent::types::McpServerConnectionStatus::Connected, + server_info: None, + error: None, + config: None, + scope: None, + tools: Vec::new(), + }); + + refresh_mcp_snapshot(&mut app); + + let envelope = rx.try_recv().expect("mcp snapshot command"); + assert_eq!( + envelope.command, + BridgeCommand::GetMcpSnapshot { session_id: "session-1".to_owned() } + ); + assert!(app.mcp.servers.is_empty()); + assert!(app.mcp.in_flight); +} + +#[test] +fn refresh_mcp_snapshot_if_needed_skips_outside_mcp_tab() { + let (_dir, mut app) = open_settings_test_app(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some(crate::agent::model::SessionId::new("session-1")); + app.config.active_tab = ConfigTab::Status; + + super::mcp::refresh_mcp_snapshot_if_needed(&mut app); + + assert!(rx.try_recv().is_err()); + assert!(!app.mcp.in_flight); +} + +#[test] +fn claudeai_proxy_server_shows_disabled_authenticate_action() { + let server = crate::agent::types::McpServerStatus { + name: "claude.ai Google Calendar".to_owned(), + status: crate::agent::types::McpServerConnectionStatus::NeedsAuth, + server_info: None, + error: Some( + "MCP server requires authentication but no OAuth token is configured.".to_owned(), + ), + config: Some(crate::agent::types::McpServerStatusConfig::ClaudeaiProxy { + url: "https://mcp-proxy.anthropic.com/v1/mcp/server".to_owned(), + id: "mcpsrv_test".to_owned(), + }), + scope: Some("session".to_owned()), + tools: Vec::new(), + }; + + let actions = available_mcp_actions(&server); + + assert!(actions.contains(&super::mcp::McpServerActionKind::Authenticate)); + assert!(!super::mcp::is_mcp_action_available( + &server, + super::mcp::McpServerActionKind::Authenticate + )); + assert!(actions.contains(&super::mcp::McpServerActionKind::Reconnect)); +} + +#[test] +fn esc_closes_settings_without_editing_selected_row() { + let (_dir, mut app) = open_settings_test_app(); + select_setting(&mut app, SettingId::FastMode); + + handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.active_view, ActiveView::Chat); + assert!(!app.config.fast_mode_effective()); +} + +#[test] +fn save_failure_keeps_previous_value_and_surfaces_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + + open(&mut app).expect("open"); + app.config.settings_path = Some(PathBuf::new()); + select_setting(&mut app, SettingId::FastMode); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!(app.active_view, ActiveView::Config); + assert!(!app.config.fast_mode_effective()); + assert!(app.config.last_error.is_some()); + assert!(app.config.status_message.is_none()); +} diff --git a/claude-code-rust/src/app/connect/bridge_lifecycle.rs b/claude-code-rust/src/app/connect/bridge_lifecycle.rs new file mode 100644 index 0000000..b567a89 --- /dev/null +++ b/claude-code-rust/src/app/connect/bridge_lifecycle.rs @@ -0,0 +1,270 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Bridge process lifecycle: spawning, initialization handshake, event loop, +//! and connection slot management. + +use crate::agent::bridge::BridgeLauncher; +use crate::agent::client::{AgentConnection, BridgeClient}; +use crate::agent::events::ClientEvent; +use crate::agent::wire::{BridgeCommand, BridgeEvent, CommandEnvelope}; +use crate::error::AppError; +use std::rc::Rc; +use std::time::Duration; +use tokio::sync::mpsc; + +use super::event_dispatch::handle_bridge_event; +use super::{ConnectionSlot, StartConnectionParams, extract_app_error}; + +pub(super) async fn run_connection_task( + params: StartConnectionParams, + conn_slot_writer: Rc>>, +) { + tracing::debug!("starting agent bridge connection task"); + + let Some(launcher) = resolve_launcher(¶ms) else { + return; + }; + let Some(mut bridge) = spawn_bridge_client(¶ms.event_tx, &launcher) else { + return; + }; + + let mut connected_once = false; + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); + publish_connection_slot(&conn_slot_writer, &cmd_tx); + + if !send_initialize_command(¶ms, &mut bridge).await { + return; + } + if let Err(app_error) = wait_for_bridge_initialized( + &mut bridge, + ¶ms.event_tx, + &cmd_tx, + &mut connected_once, + params.resume_requested, + ) + .await + { + emit_connection_failed( + ¶ms.event_tx, + "Bridge did not complete initialization".to_owned(), + app_error, + ); + return; + } + if !send_session_command(¶ms, &mut bridge).await { + return; + } + + bridge_event_loop(¶ms, &mut bridge, &cmd_tx, &mut cmd_rx, &mut connected_once).await; +} + +fn resolve_launcher(params: &StartConnectionParams) -> Option { + match crate::agent::bridge::resolve_bridge_launcher(params.bridge_script.as_deref()) { + Ok(launcher) => { + tracing::info!("resolved bridge launcher: {}", launcher.describe()); + Some(launcher) + } + Err(err) => { + tracing::error!("failed to resolve bridge launcher: {err}"); + let app_error = extract_app_error(&err).unwrap_or(AppError::ConnectionFailed); + emit_connection_failed( + ¶ms.event_tx, + format!("Failed to resolve bridge launcher: {err}"), + app_error, + ); + None + } + } +} + +fn spawn_bridge_client( + event_tx: &mpsc::UnboundedSender, + launcher: &BridgeLauncher, +) -> Option { + match BridgeClient::spawn(launcher) { + Ok(client) => { + tracing::debug!("bridge process spawned"); + Some(client) + } + Err(err) => { + tracing::error!("failed to spawn bridge process: {err}"); + let app_error = extract_app_error(&err).unwrap_or(AppError::AdapterCrashed); + emit_connection_failed(event_tx, format!("Failed to spawn bridge: {err}"), app_error); + None + } + } +} + +fn publish_connection_slot( + conn_slot_writer: &Rc>>, + cmd_tx: &mpsc::UnboundedSender, +) { + *conn_slot_writer.borrow_mut() = + Some(ConnectionSlot { conn: Rc::new(AgentConnection::new(cmd_tx.clone())) }); +} + +async fn send_initialize_command( + params: &StartConnectionParams, + bridge: &mut BridgeClient, +) -> bool { + let init_cmd = CommandEnvelope { + request_id: None, + command: BridgeCommand::Initialize { + cwd: params.cwd_raw.clone(), + metadata: std::collections::BTreeMap::new(), + }, + }; + if let Err(err) = bridge.send(init_cmd).await { + tracing::error!("failed to send initialize command to bridge: {err}"); + emit_connection_failed( + ¶ms.event_tx, + format!("Failed to initialize bridge: {err}"), + AppError::ConnectionFailed, + ); + return false; + } + tracing::debug!("sent initialize command to bridge"); + true +} + +fn build_session_command(params: &StartConnectionParams) -> CommandEnvelope { + if let Some(resume) = ¶ms.resume_id { + CommandEnvelope { + request_id: None, + command: BridgeCommand::ResumeSession { + session_id: resume.clone(), + launch_settings: params.session_launch_settings.clone(), + metadata: std::collections::BTreeMap::new(), + }, + } + } else { + CommandEnvelope { + request_id: None, + command: BridgeCommand::CreateSession { + cwd: params.cwd_raw.clone(), + resume: None, + launch_settings: params.session_launch_settings.clone(), + metadata: std::collections::BTreeMap::new(), + }, + } + } +} + +async fn send_session_command(params: &StartConnectionParams, bridge: &mut BridgeClient) -> bool { + let command = build_session_command(params); + if let Err(err) = bridge.send(command).await { + tracing::error!("failed to send create/resume session command to bridge: {err}"); + emit_connection_failed( + ¶ms.event_tx, + format!("Failed to create bridge session: {err}"), + AppError::ConnectionFailed, + ); + return false; + } + tracing::debug!("sent create/resume session command to bridge"); + true +} + +async fn bridge_event_loop( + params: &StartConnectionParams, + bridge: &mut BridgeClient, + cmd_tx: &mpsc::UnboundedSender, + cmd_rx: &mut mpsc::UnboundedReceiver, + connected_once: &mut bool, +) { + loop { + tokio::select! { + Some(cmd) = cmd_rx.recv() => { + if let Err(err) = bridge.send(cmd).await { + tracing::error!("failed to forward command to bridge: {err}"); + emit_connection_failed( + ¶ms.event_tx, + format!("Failed to send bridge command: {err}"), + AppError::ConnectionFailed, + ); + break; + } + } + event = bridge.recv() => { + match event { + Ok(Some(envelope)) => { + handle_bridge_event( + ¶ms.event_tx, + cmd_tx, + connected_once, + params.resume_requested, + envelope, + ); + } + Ok(None) => { + tracing::error!("bridge stdout closed unexpectedly"); + emit_connection_failed( + ¶ms.event_tx, + "Bridge process exited unexpectedly".to_owned(), + AppError::ConnectionFailed, + ); + break; + } + Err(err) => { + tracing::error!("bridge communication failure: {err}"); + emit_connection_failed( + ¶ms.event_tx, + format!("Bridge communication failure: {err}"), + AppError::ConnectionFailed, + ); + break; + } + } + } + } + } +} + +pub(super) fn emit_connection_failed( + event_tx: &mpsc::UnboundedSender, + message: String, + app_error: AppError, +) { + let _ = event_tx.send(ClientEvent::ConnectionFailed(message)); + let _ = event_tx.send(ClientEvent::FatalError(app_error)); +} + +pub(super) async fn wait_for_bridge_initialized( + bridge: &mut BridgeClient, + event_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, + connected_once: &mut bool, + resume_requested: bool, +) -> Result<(), AppError> { + let timeout = Duration::from_secs(10); + let started = tokio::time::Instant::now(); + loop { + let elapsed = tokio::time::Instant::now().saturating_duration_since(started); + let remaining = timeout.saturating_sub(elapsed); + if remaining.is_zero() { + return Err(AppError::ConnectionFailed); + } + + let event = tokio::time::timeout(remaining, bridge.recv()).await; + match event { + Ok(Ok(Some(envelope))) => { + if matches!(envelope.event, BridgeEvent::Initialized { .. }) { + return Ok(()); + } + if matches!(envelope.event, BridgeEvent::ConnectionFailed { .. }) { + handle_bridge_event( + event_tx, + cmd_tx, + connected_once, + resume_requested, + envelope, + ); + return Err(AppError::ConnectionFailed); + } + handle_bridge_event(event_tx, cmd_tx, connected_once, resume_requested, envelope); + } + Ok(Ok(None) | Err(_)) | Err(_) => return Err(AppError::ConnectionFailed), + } + } +} diff --git a/claude-code-rust/src/app/connect/event_dispatch.rs b/claude-code-rust/src/app/connect/event_dispatch.rs new file mode 100644 index 0000000..a16bce4 --- /dev/null +++ b/claude-code-rust/src/app/connect/event_dispatch.rs @@ -0,0 +1,333 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Bridge event dispatch: routes incoming `BridgeEvent` envelopes to appropriate +//! `ClientEvent` messages, and handles permission request/response forwarding. + +use crate::agent::error_handling::parse_turn_error_class; +use crate::agent::events::ClientEvent; +use crate::agent::model; +use crate::agent::types; +use crate::agent::wire::{BridgeCommand, CommandEnvelope, EventEnvelope}; +use crate::error::AppError; +use tokio::sync::mpsc; + +use super::bridge_lifecycle::emit_connection_failed; +use super::type_converters::{ + convert_mode_state, map_available_models, map_permission_request, map_question_request, + map_session_update, +}; + +struct ConnectedEventData { + session_id: String, + cwd: String, + model_name: String, + available_models: Vec, + mode: Option, + history_updates: Option>, +} + +#[allow(clippy::too_many_lines)] +pub(super) fn handle_bridge_event( + event_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, + connected_once: &mut bool, + resume_requested: bool, + envelope: EventEnvelope, +) { + match envelope.event { + crate::agent::wire::BridgeEvent::Connected { + session_id, + cwd, + model_name, + available_models, + mode, + history_updates, + } => { + handle_connected_event( + event_tx, + connected_once, + ConnectedEventData { + session_id, + cwd, + model_name, + available_models, + mode, + history_updates, + }, + ); + } + crate::agent::wire::BridgeEvent::AuthRequired { method_name, method_description } => { + tracing::warn!( + "bridge reported auth required: method={} desc={}", + method_name, + method_description + ); + let _ = event_tx.send(ClientEvent::AuthRequired { method_name, method_description }); + } + crate::agent::wire::BridgeEvent::ConnectionFailed { message } => { + tracing::error!("bridge connection_failed: {message}"); + emit_connection_failed(event_tx, message, AppError::ConnectionFailed); + } + crate::agent::wire::BridgeEvent::SessionUpdate { update, .. } => { + if let Some(update) = map_session_update(update) { + let _ = event_tx.send(ClientEvent::SessionUpdate(update)); + } + } + crate::agent::wire::BridgeEvent::PermissionRequest { session_id, request } => { + handle_permission_request_event(event_tx, cmd_tx, session_id, request); + } + crate::agent::wire::BridgeEvent::QuestionRequest { session_id, request } => { + handle_question_request_event(event_tx, cmd_tx, session_id, request); + } + crate::agent::wire::BridgeEvent::ElicitationRequest { session_id, request } => { + handle_elicitation_request_event(event_tx, &session_id, request); + } + crate::agent::wire::BridgeEvent::ElicitationComplete { + elicitation_id, + server_name, + .. + } => { + let _ = + event_tx.send(ClientEvent::McpElicitationCompleted { elicitation_id, server_name }); + } + crate::agent::wire::BridgeEvent::McpAuthRedirect { redirect, .. } => { + let _ = event_tx.send(ClientEvent::McpAuthRedirect { redirect }); + } + crate::agent::wire::BridgeEvent::McpOperationError { error, .. } => { + tracing::warn!( + "bridge mcp_operation_error: operation={} server={} message={}", + error.operation, + error.server_name.as_deref().unwrap_or(""), + error.message + ); + let _ = event_tx.send(ClientEvent::McpOperationError { error }); + } + crate::agent::wire::BridgeEvent::TurnComplete { .. } => { + let _ = event_tx.send(ClientEvent::TurnComplete); + } + crate::agent::wire::BridgeEvent::TurnError { message, error_kind, .. } => { + tracing::warn!("bridge turn_error: {message}"); + if let Some(class) = error_kind.as_deref().and_then(parse_turn_error_class) { + let _ = event_tx.send(ClientEvent::TurnErrorClassified { message, class }); + } else { + let _ = event_tx.send(ClientEvent::TurnError(message)); + } + } + crate::agent::wire::BridgeEvent::SlashError { message, .. } => { + tracing::warn!("bridge slash_error: {message}"); + if resume_requested + && !*connected_once + && message.to_ascii_lowercase().contains("unknown session") + { + let _ = event_tx.send(ClientEvent::FatalError(AppError::SessionNotFound)); + return; + } + let _ = event_tx.send(ClientEvent::SlashCommandError(message)); + } + crate::agent::wire::BridgeEvent::SessionReplaced { + session_id, + cwd, + model_name, + available_models, + mode, + history_updates, + } => { + let history_updates = history_updates + .unwrap_or_default() + .into_iter() + .filter_map(map_session_update) + .collect(); + let _ = event_tx.send(ClientEvent::SessionReplaced { + session_id: model::SessionId::new(session_id), + cwd, + model_name, + available_models: map_available_models(available_models), + mode: mode.map(convert_mode_state), + history_updates, + }); + } + crate::agent::wire::BridgeEvent::SessionsListed { sessions } => { + let _ = event_tx.send(ClientEvent::SessionsListed { sessions }); + } + crate::agent::wire::BridgeEvent::Initialized { .. } => {} + crate::agent::wire::BridgeEvent::StatusSnapshot { session_id, account } => { + let _ = event_tx.send(ClientEvent::StatusSnapshotReceived { session_id, account }); + } + crate::agent::wire::BridgeEvent::McpSnapshot { session_id, servers, error } => { + let _ = event_tx.send(ClientEvent::McpSnapshotReceived { session_id, servers, error }); + } + } +} + +fn handle_connected_event( + event_tx: &mpsc::UnboundedSender, + connected_once: &mut bool, + event: ConnectedEventData, +) { + tracing::info!( + "bridge connected: session_id={} cwd={} model={}", + event.session_id, + event.cwd, + event.model_name + ); + let mode = event.mode.map(convert_mode_state); + let history_updates = event + .history_updates + .unwrap_or_default() + .into_iter() + .filter_map(map_session_update) + .collect(); + if *connected_once { + let _ = event_tx.send(ClientEvent::SessionReplaced { + session_id: model::SessionId::new(event.session_id), + cwd: event.cwd, + model_name: event.model_name, + available_models: map_available_models(event.available_models), + mode, + history_updates, + }); + } else { + *connected_once = true; + let _ = event_tx.send(ClientEvent::Connected { + session_id: model::SessionId::new(event.session_id), + cwd: event.cwd, + model_name: event.model_name, + available_models: map_available_models(event.available_models), + mode, + history_updates, + }); + } +} + +fn handle_permission_request_event( + event_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, + session_id: String, + request: types::PermissionRequest, +) { + tracing::debug!( + "bridge permission_request: session_id={} tool_call_id={} options={}", + session_id, + request.tool_call.tool_call_id, + request.options.len() + ); + let (request, tool_call_id) = map_permission_request(&session_id, request); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + if event_tx.send(ClientEvent::PermissionRequest { request, response_tx }).is_ok() { + spawn_permission_response_forwarder(cmd_tx.clone(), response_rx, session_id, tool_call_id); + } +} + +fn handle_question_request_event( + event_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, + session_id: String, + request: types::QuestionRequest, +) { + tracing::debug!( + "bridge question_request: session_id={} tool_call_id={} options={}", + session_id, + request.tool_call.tool_call_id, + request.prompt.options.len() + ); + let (request, tool_call_id) = map_question_request(&session_id, request); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + if event_tx.send(ClientEvent::QuestionRequest { request, response_tx }).is_ok() { + spawn_question_response_forwarder(cmd_tx.clone(), response_rx, session_id, tool_call_id); + } +} + +fn handle_elicitation_request_event( + event_tx: &mpsc::UnboundedSender, + session_id: &str, + request: types::ElicitationRequest, +) { + tracing::debug!( + "bridge elicitation_request: session_id={} request_id={} server_name={} mode={:?}", + session_id, + request.request_id, + request.server_name, + request.mode + ); + let _ = event_tx.send(ClientEvent::McpElicitationRequest { request }); +} + +fn spawn_permission_response_forwarder( + cmd_tx: mpsc::UnboundedSender, + response_rx: tokio::sync::oneshot::Receiver, + session_id: String, + tool_call_id: String, +) { + tokio::task::spawn_local(async move { + let Ok(response) = response_rx.await else { + return; + }; + let outcome = match response.outcome { + model::RequestPermissionOutcome::Selected(selected) => { + let option_id = selected.option_id.clone(); + tracing::debug!( + "forward permission_response: session_id={} tool_call_id={} option_id={}", + session_id, + tool_call_id, + option_id + ); + types::PermissionOutcome::Selected { option_id } + } + model::RequestPermissionOutcome::Cancelled => { + tracing::debug!( + "forward permission_response: session_id={} tool_call_id={} outcome=cancelled", + session_id, + tool_call_id + ); + types::PermissionOutcome::Cancelled + } + }; + let _ = cmd_tx.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::PermissionResponse { session_id, tool_call_id, outcome }, + }); + }); +} + +fn spawn_question_response_forwarder( + cmd_tx: mpsc::UnboundedSender, + response_rx: tokio::sync::oneshot::Receiver, + session_id: String, + tool_call_id: String, +) { + tokio::task::spawn_local(async move { + let Ok(response) = response_rx.await else { + return; + }; + let outcome = match response.outcome { + model::RequestQuestionOutcome::Answered(answered) => { + tracing::debug!( + "forward question_response: session_id={} tool_call_id={} selections={}", + session_id, + tool_call_id, + answered.selected_option_ids.len() + ); + types::QuestionOutcome::Answered { + selected_option_ids: answered.selected_option_ids, + annotation: answered.annotation.map(|annotation| types::QuestionAnnotation { + preview: annotation.preview, + notes: annotation.notes, + }), + } + } + model::RequestQuestionOutcome::Cancelled => { + tracing::debug!( + "forward question_response: session_id={} tool_call_id={} outcome=cancelled", + session_id, + tool_call_id + ); + types::QuestionOutcome::Cancelled + } + }; + let _ = cmd_tx.send(CommandEnvelope { + request_id: None, + command: BridgeCommand::QuestionResponse { session_id, tool_call_id, outcome }, + }); + }); +} diff --git a/claude-code-rust/src/app/connect/mod.rs b/claude-code-rust/src/app/connect/mod.rs new file mode 100644 index 0000000..d901e4e --- /dev/null +++ b/claude-code-rust/src/app/connect/mod.rs @@ -0,0 +1,277 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! App creation and bridge connection lifecycle. +//! +//! Submodules: +//! - `bridge_lifecycle`: spawning the bridge process, init handshake, event loop +//! - `event_dispatch`: routing `BridgeEvent` envelopes to `ClientEvent` messages +//! - `type_converters`: bridge wire types -> app model types + +mod bridge_lifecycle; +mod event_dispatch; +mod session_start; +mod type_converters; + +use super::config::ConfigState; +use super::dialog::DialogState; +use super::plugins::PluginsState; +use super::state::{ + CacheMetrics, HistoryRetentionPolicy, HistoryRetentionStats, RenderCacheBudget, +}; +use super::trust; +use super::view::ActiveView; +use super::{App, AppStatus, ChatViewport, FocusManager, HelpView, SelectionState, TodoItem}; +use crate::Cli; +use crate::agent::client::AgentConnection; +use crate::agent::events::ClientEvent; +use crate::agent::model; +use crate::agent::wire::SessionLaunchSettings; +use crate::error::AppError; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::rc::Rc; +use tokio::sync::mpsc; + +/// Shorten cwd for display: use `~` for the home directory prefix. +fn shorten_cwd(cwd: &std::path::Path) -> String { + let cwd_str = cwd.to_string_lossy().to_string(); + if let Some(home) = dirs::home_dir() { + let home_str = home.to_string_lossy().to_string(); + if cwd_str.starts_with(&home_str) { + return format!("~{}", &cwd_str[home_str.len()..]); + } + } + cwd_str +} + +fn resolve_startup_cwd(cli: &Cli) -> PathBuf { + cli.dir + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) +} + +fn extract_app_error(err: &anyhow::Error) -> Option { + err.chain().find_map(|cause| cause.downcast_ref::().cloned()) +} + +struct StartConnectionParams { + event_tx: mpsc::UnboundedSender, + cwd_raw: String, + bridge_script: Option, + resume_id: Option, + resume_requested: bool, + session_launch_settings: SessionLaunchSettings, +} + +pub(crate) use session_start::{SessionStartReason, resume_session, start_new_session}; + +/// Create the `App` struct in `Connecting` state and load shared settings state. +#[allow(clippy::too_many_lines)] +pub fn create_app(cli: &Cli) -> App { + let cwd = resolve_startup_cwd(cli); + + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let terminals: crate::agent::events::TerminalMap = + Rc::new(std::cell::RefCell::new(HashMap::new())); + + let cwd_display = shorten_cwd(&cwd); + let initial_model_name = "Connecting...".to_owned(); + + let mut app = App { + active_view: ActiveView::Chat, + config: ConfigState::default(), + trust: trust::TrustState::default(), + settings_home_override: None, + messages: vec![super::ChatMessage::welcome_with_recent( + &initial_model_name, + &cwd_display, + &[], + )], + message_retained_bytes: Vec::new(), + retained_history_bytes: 0, + viewport: ChatViewport::new(), + input: super::InputState::new(), + status: AppStatus::Connecting, + resuming_session_id: None, + pending_command_label: None, + pending_command_ack: None, + should_quit: false, + exit_error: None, + session_id: None, + conn: None, + session_scope_epoch: 0, + model_name: initial_model_name, + welcome_model_resolved: false, + cwd_raw: cwd.to_string_lossy().to_string(), + cwd: cwd_display, + files_accessed: 0, + mode: None, + config_options: std::collections::BTreeMap::new(), + login_hint: None, + pending_compact_clear: false, + help_view: HelpView::Keys, + help_open: false, + help_dialog: DialogState::default(), + help_visible_count: 0, + pending_interaction_ids: Vec::new(), + cancelled_turn_pending_hint: false, + pending_cancel_origin: None, + pending_auto_submit_after_cancel: false, + event_tx, + event_rx, + spinner_frame: 0, + spinner_last_advance_at: None, + active_turn_assistant_message_idx: None, + tools_collapsed: true, + active_task_ids: HashSet::new(), + tool_call_scopes: HashMap::new(), + active_subagent_tool_ids: HashSet::new(), + subagent_idle_since: None, + terminals, + force_redraw: false, + tool_call_index: HashMap::new(), + todos: Vec::::new(), + show_todo_panel: false, + todo_scroll: 0, + todo_selected: 0, + focus: FocusManager::default(), + available_commands: Vec::new(), + plugins: PluginsState::default(), + available_agents: Vec::new(), + available_models: Vec::new(), + recent_sessions: Vec::new(), + cached_frame_area: ratatui::layout::Rect::new(0, 0, 0, 0), + selection: Option::::None, + scrollbar_drag: None, + rendered_chat_lines: Vec::new(), + rendered_chat_area: ratatui::layout::Rect::new(0, 0, 0, 0), + rendered_input_lines: Vec::new(), + rendered_input_area: ratatui::layout::Rect::new(0, 0, 0, 0), + mention: None, + slash: None, + subagent: None, + pending_submit: None, + paste_burst: super::paste_burst::PasteBurstDetector::new(), + pending_paste_text: String::new(), + pending_paste_session: None, + active_paste_session: None, + next_paste_session_id: 1, + cached_todo_compact: None, + git_branch: None, + update_check_hint: None, + session_usage: super::SessionUsageState::default(), + usage: super::UsageState::default(), + mcp: super::McpState::default(), + fast_mode_state: model::FastModeState::Off, + last_rate_limit_update: None, + is_compacting: false, + account_info: None, + terminal_tool_calls: Vec::new(), + terminal_tool_call_membership: HashSet::new(), + needs_redraw: true, + notifications: super::notify::NotificationManager::new(), + perf: cli + .perf_log + .as_deref() + .and_then(|path| crate::perf::PerfLogger::open(path, cli.perf_append)), + render_cache_budget: RenderCacheBudget::default(), + render_cache_slots: Vec::new(), + render_cache_total_bytes: 0, + render_cache_protected_bytes: 0, + render_cache_evictable: std::collections::BTreeSet::new(), + render_cache_tail_msg_idx: None, + history_retention: HistoryRetentionPolicy::default(), + history_retention_stats: HistoryRetentionStats::default(), + cache_metrics: CacheMetrics::default(), + fps_ema: None, + last_frame_at: None, + startup_connection_requested: false, + connection_started: false, + startup_bridge_script: cli.bridge_script.clone(), + startup_resume_id: cli.resume.clone(), + startup_resume_requested: cli.resume.is_some(), + }; + + if let Err(err) = super::config::initialize_shared_state(&mut app) { + tracing::warn!("failed to initialize shared settings state: {err}"); + app.config.last_error = Some(err); + } + + app.rebuild_history_retention_accounting(); + app.rebuild_render_cache_accounting(); + trust::initialize(&mut app); + app.refresh_git_branch(); + app +} + +/// Spawn the background bridge task. +pub fn start_connection(app: &mut App) { + if !app.startup_connection_requested || app.connection_started { + return; + } + + app.connection_started = true; + let params = StartConnectionParams { + event_tx: app.event_tx.clone(), + cwd_raw: app.cwd_raw.clone(), + bridge_script: app.startup_bridge_script.clone(), + resume_id: app.startup_resume_id.clone(), + resume_requested: app.startup_resume_requested, + session_launch_settings: session_start::session_launch_settings_for_reason( + app, + session_start::SessionStartReason::Startup, + ), + }; + let conn_slot: Rc>> = + Rc::new(std::cell::RefCell::new(None)); + let conn_slot_writer = Rc::clone(&conn_slot); + + tokio::task::spawn_local(async move { + bridge_lifecycle::run_connection_task(params, conn_slot_writer).await; + }); + + CONN_SLOT.with(|slot| { + debug_assert!( + slot.borrow().is_none(), + "CONN_SLOT already populated -- start_connection() called twice?" + ); + *slot.borrow_mut() = Some(conn_slot); + }); +} + +/// Shared slot for passing `Rc` from the background task to the event loop. +pub struct ConnectionSlot { + pub conn: Rc, +} + +thread_local! { + pub static CONN_SLOT: std::cell::RefCell>>>> = + const { std::cell::RefCell::new(None) }; +} + +/// Take the connection data from the thread-local slot. +pub(super) fn take_connection_slot() -> Option { + CONN_SLOT.with(|slot| slot.borrow().as_ref().and_then(|inner| inner.borrow_mut().take())) +} + +#[cfg(test)] +mod tests { + use super::type_converters::map_session_update; + use crate::agent::model; + use crate::agent::types; + + #[test] + fn map_session_update_preserves_config_option_update() { + let mapped = map_session_update(types::SessionUpdate::ConfigOptionUpdate { + option_id: "model".to_owned(), + value: serde_json::Value::String("sonnet".to_owned()), + }); + + let Some(model::SessionUpdate::ConfigOptionUpdate(cfg)) = mapped else { + panic!("expected ConfigOptionUpdate mapping"); + }; + assert_eq!(cfg.option_id, "model"); + assert_eq!(cfg.value, serde_json::Value::String("sonnet".to_owned())); + } +} diff --git a/claude-code-rust/src/app/connect/session_start.rs b/claude-code-rust/src/app/connect/session_start.rs new file mode 100644 index 0000000..b6c654a --- /dev/null +++ b/claude-code-rust/src/app/connect/session_start.rs @@ -0,0 +1,253 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::agent::client::AgentConnection; +use crate::agent::wire::SessionLaunchSettings; +use crate::app::App; +use crate::app::config::{language_input_validation_message, store}; +use serde_json::{Map, Value, json}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SessionStartReason { + Startup, + NewSession, + Resume, + Login, + Logout, +} + +pub(crate) fn session_launch_settings_for_reason( + app: &App, + reason: SessionStartReason, +) -> SessionLaunchSettings { + match reason { + SessionStartReason::Logout => SessionLaunchSettings::default(), + SessionStartReason::Startup + | SessionStartReason::NewSession + | SessionStartReason::Resume + | SessionStartReason::Login => { + let language = store::language(&app.config.committed_settings_document) + .ok() + .flatten() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .filter(|value| language_input_validation_message(value).is_none()); + SessionLaunchSettings { + language, + settings: Some(build_session_settings_object(app)), + agent_progress_summaries: Some(true), + } + } + } +} + +fn build_session_settings_object(app: &App) -> Value { + let mut settings = Map::new(); + + settings.insert( + "alwaysThinkingEnabled".to_owned(), + Value::Bool(app.config.always_thinking_effective()), + ); + + if let Some(model) = store::model(&app.config.committed_settings_document).ok().flatten() { + settings.insert("model".to_owned(), Value::String(model)); + } + + settings.insert( + "permissions".to_owned(), + json!({ + "defaultMode": app.config.default_permission_mode_effective().as_stored() + }), + ); + settings.insert("fastMode".to_owned(), Value::Bool(app.config.fast_mode_effective())); + settings.insert( + "effortLevel".to_owned(), + Value::String(app.config.thinking_effort_effective().as_stored().to_owned()), + ); + settings.insert( + "outputStyle".to_owned(), + Value::String(app.config.output_style_effective().as_stored().to_owned()), + ); + settings.insert( + "spinnerTipsEnabled".to_owned(), + Value::Bool( + store::spinner_tips_enabled(&app.config.committed_local_settings_document) + .unwrap_or(true), + ), + ); + settings.insert( + "terminalProgressBarEnabled".to_owned(), + Value::Bool( + store::terminal_progress_bar_enabled(&app.config.committed_preferences_document) + .unwrap_or(true), + ), + ); + + Value::Object(settings) +} + +pub(crate) fn start_new_session( + app: &App, + conn: &AgentConnection, + reason: SessionStartReason, +) -> anyhow::Result<()> { + conn.new_session(app.cwd_raw.clone(), session_launch_settings_for_reason(app, reason)) +} + +pub(crate) fn resume_session( + app: &App, + conn: &AgentConnection, + session_id: String, +) -> anyhow::Result<()> { + conn.resume_session( + session_id, + session_launch_settings_for_reason(app, SessionStartReason::Resume), + ) +} + +#[cfg(test)] +mod tests { + use super::{SessionStartReason, session_launch_settings_for_reason}; + use crate::agent::model::EffortLevel; + use crate::app::App; + use crate::app::config::{DefaultPermissionMode, store}; + + #[test] + fn persisted_launch_settings_include_model_and_permission_mode() { + let mut app = App::test_default(); + store::set_model(&mut app.config.committed_settings_document, Some("haiku")); + store::set_default_permission_mode( + &mut app.config.committed_settings_document, + DefaultPermissionMode::Plan, + ); + store::set_language(&mut app.config.committed_settings_document, Some("German")); + store::set_always_thinking_enabled(&mut app.config.committed_settings_document, true); + store::set_thinking_effort_level( + &mut app.config.committed_settings_document, + EffortLevel::High, + ); + + let launch_settings = session_launch_settings_for_reason(&app, SessionStartReason::Startup); + + assert_eq!(launch_settings.language.as_deref(), Some("German")); + assert_eq!( + launch_settings.settings, + Some(serde_json::json!({ + "alwaysThinkingEnabled": true, + "model": "haiku", + "permissions": { "defaultMode": "plan" }, + "fastMode": false, + "effortLevel": "high", + "outputStyle": "Default", + "spinnerTipsEnabled": true, + "terminalProgressBarEnabled": true + })) + ); + assert_eq!(launch_settings.agent_progress_summaries, Some(true)); + } + + #[test] + fn persisted_launch_settings_trim_language_value() { + let mut app = App::test_default(); + app.config.committed_settings_document = serde_json::json!({ "language": " German " }); + + let launch_settings = session_launch_settings_for_reason(&app, SessionStartReason::Startup); + + assert_eq!(launch_settings.language.as_deref(), Some("German")); + } + + #[test] + fn persisted_launch_settings_default_permission_mode_when_missing() { + let app = App::test_default(); + + let launch_settings = + session_launch_settings_for_reason(&app, SessionStartReason::NewSession); + + assert_eq!(launch_settings.language, None); + assert_eq!( + launch_settings.settings, + Some(serde_json::json!({ + "alwaysThinkingEnabled": false, + "permissions": { "defaultMode": "default" }, + "fastMode": false, + "effortLevel": "medium", + "outputStyle": "Default", + "spinnerTipsEnabled": true, + "terminalProgressBarEnabled": true + })) + ); + assert_eq!(launch_settings.agent_progress_summaries, Some(true)); + } + + #[test] + fn persisted_launch_settings_include_supported_settings_json_without_model_when_unset() { + let mut app = App::test_default(); + store::set_always_thinking_enabled(&mut app.config.committed_settings_document, true); + store::set_thinking_effort_level( + &mut app.config.committed_settings_document, + EffortLevel::High, + ); + store::set_fast_mode(&mut app.config.committed_settings_document, true); + store::set_output_style( + &mut app.config.committed_local_settings_document, + crate::app::config::OutputStyle::Learning, + ); + store::set_spinner_tips_enabled(&mut app.config.committed_local_settings_document, false); + store::set_terminal_progress_bar_enabled( + &mut app.config.committed_preferences_document, + false, + ); + + let launch_settings = session_launch_settings_for_reason(&app, SessionStartReason::Startup); + + assert_eq!(launch_settings.language, None); + assert_eq!( + launch_settings.settings, + Some(serde_json::json!({ + "alwaysThinkingEnabled": true, + "permissions": { "defaultMode": "default" }, + "fastMode": true, + "effortLevel": "high", + "outputStyle": "Learning", + "spinnerTipsEnabled": false, + "terminalProgressBarEnabled": false + })) + ); + assert_eq!(launch_settings.agent_progress_summaries, Some(true)); + } + + #[test] + fn persisted_launch_settings_omit_invalid_language_value() { + let mut app = App::test_default(); + app.config.committed_settings_document = serde_json::json!({ "language": "E" }); + + let launch_settings = session_launch_settings_for_reason(&app, SessionStartReason::Startup); + + assert_eq!(launch_settings.language, None); + } + + #[test] + fn persisted_launch_settings_omit_whitespace_only_language_value() { + let mut app = App::test_default(); + app.config.committed_settings_document = serde_json::json!({ "language": " " }); + + let launch_settings = session_launch_settings_for_reason(&app, SessionStartReason::Startup); + + assert_eq!(launch_settings.language, None); + } + + #[test] + fn logout_launch_settings_omit_all_overrides() { + let mut app = App::test_default(); + store::set_model(&mut app.config.committed_settings_document, Some("haiku")); + store::set_default_permission_mode( + &mut app.config.committed_settings_document, + DefaultPermissionMode::Plan, + ); + store::set_always_thinking_enabled(&mut app.config.committed_settings_document, true); + + let launch_settings = session_launch_settings_for_reason(&app, SessionStartReason::Logout); + + assert!(launch_settings.is_empty()); + } +} diff --git a/claude-code-rust/src/app/connect/type_converters.rs b/claude-code-rust/src/app/connect/type_converters.rs new file mode 100644 index 0000000..eaafa56 --- /dev/null +++ b/claude-code-rust/src/app/connect/type_converters.rs @@ -0,0 +1,764 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Type conversion functions: bridge wire types -> app model types. + +use crate::agent::model; +use crate::agent::types; +use crate::app::{ModeInfo, ModeState}; + +pub(super) fn map_rate_limit_status(status: types::RateLimitStatus) -> model::RateLimitStatus { + match status { + types::RateLimitStatus::Allowed => model::RateLimitStatus::Allowed, + types::RateLimitStatus::AllowedWarning => model::RateLimitStatus::AllowedWarning, + types::RateLimitStatus::Rejected => model::RateLimitStatus::Rejected, + } +} + +pub(super) fn map_rate_limit_update(update: types::RateLimitUpdate) -> model::RateLimitUpdate { + model::RateLimitUpdate { + status: map_rate_limit_status(update.status), + resets_at: update.resets_at, + utilization: update.utilization, + rate_limit_type: update.rate_limit_type, + overage_status: update.overage_status.map(map_rate_limit_status), + overage_resets_at: update.overage_resets_at, + overage_disabled_reason: update.overage_disabled_reason, + is_using_overage: update.is_using_overage, + surpassed_threshold: update.surpassed_threshold, + } +} + +pub(super) fn map_available_commands_update( + commands: Vec, +) -> model::AvailableCommandsUpdate { + model::AvailableCommandsUpdate::new( + commands + .into_iter() + .map(|cmd| { + let mut mapped = model::AvailableCommand::new(cmd.name, cmd.description); + if let Some(input_hint) = cmd.input_hint + && !input_hint.trim().is_empty() + { + mapped = mapped.input_hint(input_hint); + } + mapped + }) + .collect(), + ) +} + +pub(super) fn map_available_agents_update( + agents: Vec, +) -> model::AvailableAgentsUpdate { + model::AvailableAgentsUpdate::new( + agents + .into_iter() + .map(|agent| { + let mut mapped = model::AvailableAgent::new(agent.name, agent.description); + if let Some(model_name) = agent.model + && !model_name.trim().is_empty() + { + mapped = mapped.model(model_name); + } + mapped + }) + .collect(), + ) +} + +pub(super) fn map_available_models( + models: Vec, +) -> Vec { + models + .into_iter() + .map(|model_info| { + let mut mapped = model::AvailableModel::new(model_info.id, model_info.display_name); + if let Some(description) = model_info.description + && !description.trim().is_empty() + { + mapped = mapped.description(description); + } + mapped = mapped.supports_effort(model_info.supports_effort); + mapped = mapped.supports_adaptive_thinking(model_info.supports_adaptive_thinking); + mapped = mapped.supports_fast_mode(model_info.supports_fast_mode); + mapped = mapped.supports_auto_mode(model_info.supports_auto_mode); + if !model_info.supported_effort_levels.is_empty() { + mapped = mapped.supported_effort_levels( + model_info + .supported_effort_levels + .into_iter() + .map(|level| match level { + types::EffortLevel::Low => model::EffortLevel::Low, + types::EffortLevel::Medium => model::EffortLevel::Medium, + types::EffortLevel::High => model::EffortLevel::High, + }) + .collect(), + ); + } + mapped + }) + .collect() +} + +#[allow(clippy::too_many_lines)] +pub(super) fn map_session_update(update: types::SessionUpdate) -> Option { + match update { + types::SessionUpdate::UserMessageChunk { content } => { + let content = convert_content_block(content)?; + Some(model::SessionUpdate::UserMessageChunk(model::ContentChunk::new(content))) + } + types::SessionUpdate::AgentMessageChunk { content } => { + let content = convert_content_block(content)?; + Some(model::SessionUpdate::AgentMessageChunk(model::ContentChunk::new(content))) + } + types::SessionUpdate::AgentThoughtChunk { content } => { + let content = convert_content_block(content)?; + Some(model::SessionUpdate::AgentThoughtChunk(model::ContentChunk::new(content))) + } + types::SessionUpdate::ToolCall { tool_call } => { + Some(model::SessionUpdate::ToolCall(convert_tool_call(tool_call))) + } + types::SessionUpdate::ToolCallUpdate { tool_call_update } => { + Some(model::SessionUpdate::ToolCallUpdate(convert_tool_call_update(tool_call_update))) + } + types::SessionUpdate::Plan { entries } => Some(model::SessionUpdate::Plan( + model::Plan::new(entries.into_iter().map(convert_plan_entry).collect()), + )), + types::SessionUpdate::AvailableCommandsUpdate { commands } => Some( + model::SessionUpdate::AvailableCommandsUpdate(map_available_commands_update(commands)), + ), + types::SessionUpdate::AvailableAgentsUpdate { agents } => { + Some(model::SessionUpdate::AvailableAgentsUpdate(map_available_agents_update(agents))) + } + types::SessionUpdate::ModeStateUpdate { mode } => { + Some(model::SessionUpdate::ModeStateUpdate(convert_mode_state(mode))) + } + types::SessionUpdate::CurrentModeUpdate { current_mode_id } => { + Some(model::SessionUpdate::CurrentModeUpdate(model::CurrentModeUpdate::new( + model::SessionModeId::new(current_mode_id), + ))) + } + types::SessionUpdate::ConfigOptionUpdate { option_id, value } => { + Some(model::SessionUpdate::ConfigOptionUpdate(model::ConfigOptionUpdate { + option_id, + value, + })) + } + types::SessionUpdate::FastModeUpdate { fast_mode_state } => { + Some(model::SessionUpdate::FastModeUpdate(match fast_mode_state { + types::FastModeState::Off => model::FastModeState::Off, + types::FastModeState::Cooldown => model::FastModeState::Cooldown, + types::FastModeState::On => model::FastModeState::On, + })) + } + types::SessionUpdate::RateLimitUpdate { + status, + resets_at, + utilization, + rate_limit_type, + overage_status, + overage_resets_at, + overage_disabled_reason, + is_using_overage, + surpassed_threshold, + } => Some(model::SessionUpdate::RateLimitUpdate(map_rate_limit_update( + types::RateLimitUpdate { + status, + resets_at, + utilization, + rate_limit_type, + overage_status, + overage_resets_at, + overage_disabled_reason, + is_using_overage, + surpassed_threshold, + }, + ))), + types::SessionUpdate::SessionStatusUpdate { status } => { + Some(model::SessionUpdate::SessionStatusUpdate(match status { + types::SessionStatus::Compacting => model::SessionStatus::Compacting, + types::SessionStatus::Idle => model::SessionStatus::Idle, + })) + } + types::SessionUpdate::CompactionBoundary { trigger, pre_tokens } => { + Some(model::SessionUpdate::CompactionBoundary(model::CompactionBoundary { + trigger: match trigger { + types::CompactionTrigger::Manual => model::CompactionTrigger::Manual, + types::CompactionTrigger::Auto => model::CompactionTrigger::Auto, + }, + pre_tokens, + })) + } + } +} + +pub(super) fn map_permission_request( + session_id: &str, + request: types::PermissionRequest, +) -> (model::RequestPermissionRequest, String) { + let tool_call_id = request.tool_call.tool_call_id.clone(); + let tool_call_meta = request.tool_call.meta.clone(); + let tool_call_fields = convert_tool_call_to_fields(request.tool_call); + let mut tool_call_update = model::ToolCallUpdate::new(tool_call_id.clone(), tool_call_fields); + if let Some(meta) = tool_call_meta { + tool_call_update = tool_call_update.meta(meta); + } + let options = request + .options + .into_iter() + .map(|opt| { + let kind = match opt.kind.as_str() { + "allow_once" => model::PermissionOptionKind::AllowOnce, + "allow_session" => model::PermissionOptionKind::AllowSession, + "allow_always" => model::PermissionOptionKind::AllowAlways, + "reject_once" => model::PermissionOptionKind::RejectOnce, + "question_choice" => model::PermissionOptionKind::QuestionChoice, + "plan_approve" => model::PermissionOptionKind::PlanApprove, + "plan_reject" => model::PermissionOptionKind::PlanReject, + _ => { + tracing::warn!( + "unknown permission option kind from bridge; defaulting to reject_once: session_id={} tool_call_id={} option_id={} option_name={} option_kind={}", + session_id, + tool_call_id, + opt.option_id, + opt.name, + opt.kind + ); + model::PermissionOptionKind::RejectOnce + } + }; + model::PermissionOption::new(opt.option_id, opt.name, kind).description(opt.description) + }) + .collect(); + ( + model::RequestPermissionRequest::new( + model::SessionId::new(session_id), + tool_call_update, + options, + ), + tool_call_id, + ) +} + +pub(super) fn map_question_request( + session_id: &str, + request: types::QuestionRequest, +) -> (model::RequestQuestionRequest, String) { + let tool_call_id = request.tool_call.tool_call_id.clone(); + let tool_call_meta = request.tool_call.meta.clone(); + let tool_call_fields = convert_tool_call_to_fields(request.tool_call); + let mut tool_call_update = model::ToolCallUpdate::new(tool_call_id.clone(), tool_call_fields); + if let Some(meta) = tool_call_meta { + tool_call_update = tool_call_update.meta(meta); + } + + let prompt = model::QuestionPrompt::new( + request.prompt.question, + request.prompt.header, + request.prompt.multi_select, + request + .prompt + .options + .into_iter() + .map(|option| { + model::QuestionOption::new(option.option_id, option.label) + .description(option.description) + .preview(option.preview) + }) + .collect(), + ); + + ( + model::RequestQuestionRequest::new( + model::SessionId::new(session_id), + tool_call_update, + prompt, + usize::try_from(request.question_index).unwrap_or(0), + usize::try_from(request.total_questions).unwrap_or(0), + ), + tool_call_id, + ) +} + +pub(super) fn convert_content_block(content: types::ContentBlock) -> Option { + match content { + types::ContentBlock::Text { text } => { + Some(model::ContentBlock::Text(model::TextContent::new(text))) + } + // Deferred for parity follow-up per scope. + types::ContentBlock::Image { .. } => None, + } +} + +pub(super) fn convert_tool_call(tool_call: types::ToolCall) -> model::ToolCall { + let types::ToolCall { + tool_call_id, + title, + kind, + status, + content, + raw_input, + raw_output, + output_metadata, + locations, + meta, + } = tool_call; + + let mut tc = model::ToolCall::new(tool_call_id, title) + .kind(convert_tool_kind(&kind)) + .status(convert_tool_status(&status)) + .content(content.into_iter().filter_map(convert_tool_call_content).collect()) + .locations( + locations + .into_iter() + .map(|loc| { + let mut location = model::ToolCallLocation::new(loc.path); + if let Some(line) = loc.line.and_then(|line| u32::try_from(line).ok()) { + location = location.line(line); + } + location + }) + .collect(), + ); + + if let Some(raw_input) = raw_input { + tc = tc.raw_input(raw_input); + } + + if let Some(raw_output) = raw_output { + tc = tc.raw_output(serde_json::Value::String(raw_output)); + } + if let Some(output_metadata) = output_metadata { + tc = tc.output_metadata(convert_tool_output_metadata(output_metadata)); + } + if let Some(meta) = meta { + tc = tc.meta(meta); + } + + tc +} + +pub(super) fn convert_tool_call_update(update: types::ToolCallUpdate) -> model::ToolCallUpdate { + let update_meta = update.fields.meta.clone(); + let mut out = model::ToolCallUpdate::new( + update.tool_call_id, + convert_tool_call_update_fields(update.fields), + ); + if let Some(meta) = update_meta { + out = out.meta(meta); + } + out +} + +pub(super) fn convert_tool_call_to_fields( + tool_call: types::ToolCall, +) -> model::ToolCallUpdateFields { + let mut fields = model::ToolCallUpdateFields::new() + .title(tool_call.title) + .kind(convert_tool_kind(&tool_call.kind)) + .status(convert_tool_status(&tool_call.status)) + .content( + tool_call.content.into_iter().filter_map(convert_tool_call_content).collect::>(), + ) + .locations( + tool_call + .locations + .into_iter() + .map(|loc| { + let mut location = model::ToolCallLocation::new(loc.path); + if let Some(line) = loc.line.and_then(|line| u32::try_from(line).ok()) { + location = location.line(line); + } + location + }) + .collect::>(), + ); + + if let Some(raw_input) = tool_call.raw_input { + fields = fields.raw_input(raw_input); + } + + if let Some(raw_output) = tool_call.raw_output { + fields = fields.raw_output(serde_json::Value::String(raw_output)); + } + if let Some(output_metadata) = tool_call.output_metadata { + fields = fields.output_metadata(convert_tool_output_metadata(output_metadata)); + } + + fields +} + +pub(super) fn convert_tool_call_update_fields( + fields: types::ToolCallUpdateFields, +) -> model::ToolCallUpdateFields { + let mut out = model::ToolCallUpdateFields::new(); + + if let Some(title) = fields.title { + out = out.title(title); + } + if let Some(kind) = fields.kind { + out = out.kind(convert_tool_kind(&kind)); + } + if let Some(status) = fields.status { + out = out.status(convert_tool_status(&status)); + } + if let Some(content) = fields.content { + out = out + .content(content.into_iter().filter_map(convert_tool_call_content).collect::>()); + } + if let Some(raw_input) = fields.raw_input { + out = out.raw_input(raw_input); + } + if let Some(raw_output) = fields.raw_output { + out = out.raw_output(serde_json::Value::String(raw_output)); + } + if let Some(output_metadata) = fields.output_metadata { + out = out.output_metadata(convert_tool_output_metadata(output_metadata)); + } + if let Some(locations) = fields.locations { + out = out.locations( + locations + .into_iter() + .map(|loc| { + let mut location = model::ToolCallLocation::new(loc.path); + if let Some(line) = loc.line.and_then(|line| u32::try_from(line).ok()) { + location = location.line(line); + } + location + }) + .collect::>(), + ); + } + + out +} + +fn convert_tool_output_metadata( + output_metadata: types::ToolOutputMetadata, +) -> model::ToolOutputMetadata { + model::ToolOutputMetadata::new() + .bash(output_metadata.bash.map(|bash| { + model::BashOutputMetadata::new() + .assistant_auto_backgrounded(bash.assistant_auto_backgrounded) + .token_saver_active(bash.token_saver_active) + })) + .exit_plan_mode(output_metadata.exit_plan_mode.map(|exit_plan_mode| { + model::ExitPlanModeOutputMetadata::new().ultraplan(exit_plan_mode.is_ultraplan) + })) + .todo_write(output_metadata.todo_write.map(|todo_write| { + model::TodoWriteOutputMetadata::new() + .verification_nudge_needed(todo_write.verification_nudge_needed) + })) +} + +fn convert_tool_call_content( + tool_content: types::ToolCallContent, +) -> Option { + match tool_content { + types::ToolCallContent::Content { content } => { + let block = convert_content_block(content)?; + Some(model::ToolCallContent::Content(model::Content::new(block))) + } + types::ToolCallContent::Diff { old_path: _, new_path, old, new, repository } => { + Some(model::ToolCallContent::Diff( + model::Diff::new(new_path, new).old_text(Some(old)).repository(repository), + )) + } + types::ToolCallContent::McpResource { uri, mime_type, text, blob_saved_to } => { + Some(model::ToolCallContent::McpResource( + model::McpResource::new(uri) + .mime_type(mime_type) + .text(text) + .blob_saved_to(blob_saved_to), + )) + } + } +} + +pub(super) fn convert_tool_kind(kind: &str) -> model::ToolKind { + match kind { + "read" => model::ToolKind::Read, + "edit" => model::ToolKind::Edit, + "delete" => model::ToolKind::Delete, + "move" => model::ToolKind::Move, + "execute" => model::ToolKind::Execute, + "search" => model::ToolKind::Search, + "fetch" => model::ToolKind::Fetch, + "switch_mode" => model::ToolKind::SwitchMode, + "other" => model::ToolKind::Other, + _ => model::ToolKind::Think, + } +} + +pub(super) fn convert_tool_status(status: &str) -> model::ToolCallStatus { + match status { + "in_progress" => model::ToolCallStatus::InProgress, + "completed" => model::ToolCallStatus::Completed, + "failed" => model::ToolCallStatus::Failed, + _ => model::ToolCallStatus::Pending, + } +} + +pub(super) fn convert_plan_entry(entry: types::PlanEntry) -> model::PlanEntry { + let status = match entry.status.as_str() { + "in_progress" => model::PlanEntryStatus::InProgress, + "completed" => model::PlanEntryStatus::Completed, + _ => model::PlanEntryStatus::Pending, + }; + model::PlanEntry::new(entry.content, model::PlanEntryPriority::Medium, status) +} + +pub(super) fn convert_mode_state(mode: types::ModeState) -> ModeState { + let available_modes: Vec = + mode.available_modes.into_iter().map(|m| ModeInfo { id: m.id, name: m.name }).collect(); + ModeState { + current_mode_id: mode.current_mode_id, + current_mode_name: mode.current_mode_name, + available_modes, + } +} + +#[cfg(test)] +mod tests { + use super::{ + convert_tool_call, convert_tool_call_update_fields, map_available_models, + map_question_request, + }; + use crate::agent::{model, types}; + + #[test] + fn map_available_models_preserves_optional_fast_and_auto_metadata() { + let mapped = map_available_models(vec![ + types::AvailableModel { + id: "sonnet".to_owned(), + display_name: "Claude Sonnet".to_owned(), + description: Some("Balanced model".to_owned()), + supports_effort: true, + supported_effort_levels: vec![ + types::EffortLevel::Low, + types::EffortLevel::Medium, + types::EffortLevel::High, + ], + supports_adaptive_thinking: Some(true), + supports_fast_mode: Some(true), + supports_auto_mode: Some(false), + }, + types::AvailableModel { + id: "haiku".to_owned(), + display_name: "Claude Haiku".to_owned(), + description: None, + supports_effort: false, + supported_effort_levels: Vec::new(), + supports_adaptive_thinking: None, + supports_fast_mode: None, + supports_auto_mode: None, + }, + ]); + + assert_eq!( + mapped, + vec![ + model::AvailableModel::new("sonnet", "Claude Sonnet") + .description("Balanced model") + .supports_effort(true) + .supported_effort_levels(vec![ + model::EffortLevel::Low, + model::EffortLevel::Medium, + model::EffortLevel::High, + ]) + .supports_adaptive_thinking(Some(true)) + .supports_fast_mode(Some(true)) + .supports_auto_mode(Some(false)), + model::AvailableModel::new("haiku", "Claude Haiku") + .supports_adaptive_thinking(None) + .supports_fast_mode(None) + .supports_auto_mode(None), + ] + ); + } + + #[test] + fn map_question_request_preserves_preview_and_annotation_shape() { + let (request, tool_call_id) = map_question_request( + "session-1", + types::QuestionRequest { + tool_call: types::ToolCall { + tool_call_id: "tool-1".to_owned(), + title: "Pick target".to_owned(), + kind: "other".to_owned(), + status: "in_progress".to_owned(), + content: Vec::new(), + raw_input: Some(serde_json::json!({ "source": "ask_user_question" })), + raw_output: None, + output_metadata: None, + locations: Vec::new(), + meta: Some( + serde_json::json!({ "claudeCode": { "toolName": "AskUserQuestion" } }), + ), + }, + prompt: types::QuestionPrompt { + question: "Where should this roll out?".to_owned(), + header: "Target".to_owned(), + multi_select: true, + options: vec![ + types::QuestionOption { + option_id: "question_0".to_owned(), + label: "Staging".to_owned(), + description: Some("Validate in staging first".to_owned()), + preview: Some("Deploy to staging first.".to_owned()), + }, + types::QuestionOption { + option_id: "question_1".to_owned(), + label: "Production".to_owned(), + description: Some("Customer-facing rollout".to_owned()), + preview: None, + }, + ], + }, + question_index: 1, + total_questions: 3, + }, + ); + + assert_eq!(tool_call_id, "tool-1"); + assert_eq!( + request, + model::RequestQuestionRequest::new( + model::SessionId::new("session-1"), + model::ToolCallUpdate::new( + "tool-1", + model::ToolCallUpdateFields::new() + .title("Pick target") + .kind(model::ToolKind::Other) + .status(model::ToolCallStatus::InProgress) + .content(Vec::new()) + .raw_input(serde_json::json!({ "source": "ask_user_question" })) + .locations(Vec::new()), + ) + .meta(serde_json::json!({ "claudeCode": { "toolName": "AskUserQuestion" } })), + model::QuestionPrompt::new( + "Where should this roll out?", + "Target", + true, + vec![ + model::QuestionOption::new("question_0", "Staging") + .description(Some("Validate in staging first".to_owned())) + .preview(Some("Deploy to staging first.".to_owned())), + model::QuestionOption::new("question_1", "Production") + .description(Some("Customer-facing rollout".to_owned())) + .preview(None), + ], + ), + 1, + 3, + ) + ); + } + + #[test] + fn convert_tool_call_update_fields_preserves_output_metadata() { + let fields = convert_tool_call_update_fields(types::ToolCallUpdateFields { + status: Some("completed".to_owned()), + output_metadata: Some(types::ToolOutputMetadata { + bash: Some(types::BashOutputMetadata { + assistant_auto_backgrounded: Some(true), + token_saver_active: Some(true), + }), + exit_plan_mode: Some(types::ExitPlanModeOutputMetadata { + is_ultraplan: Some(true), + }), + todo_write: Some(types::TodoWriteOutputMetadata { + verification_nudge_needed: Some(true), + }), + }), + ..types::ToolCallUpdateFields::default() + }); + + assert_eq!( + fields.output_metadata, + Some( + model::ToolOutputMetadata::new() + .bash(Some( + model::BashOutputMetadata::new() + .assistant_auto_backgrounded(Some(true)) + .token_saver_active(Some(true)), + )) + .exit_plan_mode(Some( + model::ExitPlanModeOutputMetadata::new().ultraplan(Some(true)), + )) + .todo_write(Some( + model::TodoWriteOutputMetadata::new().verification_nudge_needed(Some(true)), + )), + ) + ); + } + + #[test] + fn convert_tool_call_preserves_diff_repository() { + let tool_call = convert_tool_call(types::ToolCall { + tool_call_id: "tool-1".to_owned(), + title: "Write src/main.rs".to_owned(), + kind: "edit".to_owned(), + status: "completed".to_owned(), + content: vec![types::ToolCallContent::Diff { + old_path: "src/main.rs".to_owned(), + new_path: "src/main.rs".to_owned(), + old: "old".to_owned(), + new: "new".to_owned(), + repository: Some("acme/project".to_owned()), + }], + raw_input: None, + raw_output: None, + output_metadata: None, + locations: Vec::new(), + meta: None, + }); + + assert_eq!( + tool_call.content, + vec![model::ToolCallContent::Diff( + model::Diff::new("src/main.rs", "new") + .old_text(Some("old")) + .repository(Some("acme/project".to_owned())), + )] + ); + } + + #[test] + fn convert_tool_call_preserves_mcp_resource_blob_path() { + let tool_call = convert_tool_call(types::ToolCall { + tool_call_id: "tool-2".to_owned(), + title: "ReadMcpResource docs file://manual.pdf".to_owned(), + kind: "read".to_owned(), + status: "completed".to_owned(), + content: vec![types::ToolCallContent::McpResource { + uri: "file://manual.pdf".to_owned(), + mime_type: Some("application/pdf".to_owned()), + text: Some( + "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf" + .to_owned(), + ), + blob_saved_to: Some("C:\\tmp\\manual.pdf".to_owned()), + }], + raw_input: None, + raw_output: None, + output_metadata: None, + locations: Vec::new(), + meta: None, + }); + + assert_eq!( + tool_call.content, + vec![model::ToolCallContent::McpResource( + model::McpResource::new("file://manual.pdf") + .mime_type(Some("application/pdf".to_owned())) + .text(Some( + "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf" + .to_owned(), + )) + .blob_saved_to(Some("C:\\tmp\\manual.pdf".to_owned())), + )] + ); + } +} diff --git a/claude-code-rust/src/app/dialog.rs b/claude-code-rust/src/app/dialog.rs new file mode 100644 index 0000000..58f7e57 --- /dev/null +++ b/claude-code-rust/src/app/dialog.rs @@ -0,0 +1,97 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +/// Shared list-dialog navigation state used by autocomplete/dropdown UIs. +#[derive(Debug, Clone, Copy, Default)] +pub struct DialogState { + /// Index of the currently selected item. + pub selected: usize, + /// First visible item index in the scroll window. + pub scroll_offset: usize, +} + +impl DialogState { + /// Clamp selection + scroll to the current item count and viewport size. + pub fn clamp(&mut self, item_count: usize, max_visible: usize) { + if item_count == 0 || max_visible == 0 { + self.selected = 0; + self.scroll_offset = 0; + return; + } + + self.selected = self.selected.min(item_count - 1); + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } else if self.selected >= self.scroll_offset + max_visible { + self.scroll_offset = self.selected + 1 - max_visible; + } + + let max_start = item_count.saturating_sub(max_visible); + self.scroll_offset = self.scroll_offset.min(max_start); + } + + /// Move selection one item up (with wrap-around). + pub fn move_up(&mut self, item_count: usize, max_visible: usize) { + if item_count == 0 { + self.selected = 0; + self.scroll_offset = 0; + return; + } + if self.selected == 0 { + self.selected = item_count - 1; + } else { + self.selected -= 1; + } + self.clamp(item_count, max_visible); + } + + /// Move selection one item down (with wrap-around). + pub fn move_down(&mut self, item_count: usize, max_visible: usize) { + if item_count == 0 { + self.selected = 0; + self.scroll_offset = 0; + return; + } + self.selected = (self.selected + 1) % item_count; + self.clamp(item_count, max_visible); + } + + /// Compute the `[start, end)` visible slice for rendering. + #[must_use] + pub fn visible_range(&self, item_count: usize, max_visible: usize) -> (usize, usize) { + if item_count == 0 || max_visible == 0 { + return (0, 0); + } + let max_start = item_count.saturating_sub(max_visible); + let start = self.scroll_offset.min(max_start); + let end = (start + max_visible).min(item_count); + (start, end) + } +} + +#[cfg(test)] +mod tests { + use super::DialogState; + + #[test] + fn clamp_resets_when_empty() { + let mut d = DialogState { selected: 5, scroll_offset: 2 }; + d.clamp(0, 8); + assert_eq!(d.selected, 0); + assert_eq!(d.scroll_offset, 0); + } + + #[test] + fn move_down_wraps_and_updates_scroll() { + let mut d = DialogState { selected: 7, scroll_offset: 0 }; + d.move_down(8, 4); + assert_eq!(d.selected, 0); + assert_eq!(d.scroll_offset, 0); + } + + #[test] + fn visible_range_clamps_scroll_offset() { + let d = DialogState { selected: 0, scroll_offset: 10 }; + assert_eq!(d.visible_range(6, 4), (2, 6)); + } +} diff --git a/claude-code-rust/src/app/events/client.rs b/claude-code-rust/src/app/events/client.rs new file mode 100644 index 0000000..5806ef7 --- /dev/null +++ b/claude-code-rust/src/app/events/client.rs @@ -0,0 +1,186 @@ +use super::{App, session, turn}; +use crate::agent::events::ClientEvent; + +#[allow(clippy::too_many_lines)] +pub fn handle_client_event(app: &mut App, event: ClientEvent) { + app.needs_redraw = true; + match event { + ClientEvent::SessionUpdate(update) => super::handle_session_update_event(app, update), + ClientEvent::PermissionRequest { request, response_tx } => { + turn::handle_permission_request_event(app, request, response_tx); + } + ClientEvent::QuestionRequest { request, response_tx } => { + turn::handle_question_request_event(app, request, response_tx); + } + ClientEvent::McpElicitationRequest { request } => { + crate::app::config::present_mcp_elicitation_request(app, request); + } + ClientEvent::McpAuthRedirect { redirect } => { + crate::app::config::present_mcp_auth_redirect(app, redirect); + } + ClientEvent::McpOperationError { error } => { + crate::app::config::handle_mcp_operation_error(app, &error); + } + ClientEvent::McpElicitationCompleted { elicitation_id, server_name } => { + crate::app::config::handle_mcp_elicitation_completed(app, &elicitation_id, server_name); + } + ClientEvent::TurnCancelled => turn::handle_turn_cancelled_event(app), + ClientEvent::TurnComplete => turn::handle_turn_complete_event(app), + ClientEvent::TurnError(msg) => turn::handle_turn_error_event(app, &msg, None), + ClientEvent::TurnErrorClassified { message, class } => { + turn::handle_turn_error_event(app, &message, Some(class)); + } + ClientEvent::Connected { + session_id, + cwd, + model_name, + available_models, + mode, + history_updates, + } => { + session::handle_connected_client_event( + app, + session_id, + cwd, + model_name, + available_models, + mode, + &history_updates, + ); + crate::app::config::refresh_mcp_snapshot(app); + } + ClientEvent::SessionsListed { sessions } => { + session::handle_sessions_listed_event(app, sessions); + } + ClientEvent::AuthRequired { method_name, method_description } => { + session::handle_auth_required_event(app, method_name, method_description); + } + ClientEvent::ConnectionFailed(msg) => { + session::handle_connection_failed_event(app, &msg); + } + ClientEvent::SlashCommandError(msg) => { + session::handle_slash_command_error_event(app, &msg); + } + ClientEvent::SessionReplaced { + session_id, + cwd, + model_name, + available_models, + mode, + history_updates, + } => { + session::handle_session_replaced_event( + app, + session_id, + cwd, + model_name, + available_models, + mode, + &history_updates, + ); + crate::app::config::refresh_mcp_snapshot(app); + } + ClientEvent::UpdateAvailable { latest_version, current_version } => { + session::handle_update_available_event(app, &latest_version, ¤t_version); + } + ClientEvent::ServiceStatus { severity, message } => { + session::handle_service_status_event(app, severity, &message); + } + ClientEvent::AuthCompleted { conn } => { + session::handle_auth_completed_event(app, &conn); + } + ClientEvent::LogoutCompleted => { + session::handle_logout_completed_event(app); + } + ClientEvent::StatusSnapshotReceived { session_id, account } => { + if app.session_id.as_ref().map(ToString::to_string).as_deref() + != Some(session_id.as_str()) + { + return; + } + app.account_info = Some(account); + app.needs_redraw = true; + } + ClientEvent::McpSnapshotReceived { session_id, servers, error } => { + if app.session_id.as_ref().map(ToString::to_string).as_deref() + != Some(session_id.as_str()) + { + return; + } + tracing::debug!( + "received MCP snapshot: servers={} error_present={}", + servers.len(), + error.is_some() + ); + app.mcp.servers = servers; + app.mcp.in_flight = false; + app.mcp.last_error = error; + app.config.mcp_selected_server_index = + app.config.mcp_selected_server_index.min(app.mcp.servers.len().saturating_sub(1)); + if let Some(overlay) = app.config.mcp_auth_redirect_overlay() { + let server_name = overlay.redirect.server_name.clone(); + if let Some(server) = + app.mcp.servers.iter().find(|server| server.name == server_name) + && !matches!( + server.status, + crate::agent::types::McpServerConnectionStatus::NeedsAuth + | crate::agent::types::McpServerConnectionStatus::Pending + ) + { + if matches!( + server.status, + crate::agent::types::McpServerConnectionStatus::Connected + ) { + app.config.status_message = + Some(format!("{} authenticated successfully.", server.name)); + app.config.last_error = None; + } + app.config.overlay = None; + } + } + } + ClientEvent::UsageRefreshStarted { epoch } => { + if app.session_scope_epoch != epoch { + return; + } + crate::app::usage::apply_refresh_started(app); + } + ClientEvent::UsageSnapshotReceived { epoch, snapshot } => { + if app.session_scope_epoch != epoch { + return; + } + crate::app::usage::apply_refresh_success(app, snapshot); + } + ClientEvent::UsageRefreshFailed { epoch, message, source } => { + if app.session_scope_epoch != epoch { + return; + } + crate::app::usage::apply_refresh_failure(app, message, source); + } + ClientEvent::PluginsInventoryUpdated { cwd_raw, snapshot, claude_path } => { + if app.cwd_raw != cwd_raw { + return; + } + crate::app::plugins::apply_inventory_refresh_success(app, snapshot, claude_path); + } + ClientEvent::PluginsInventoryRefreshFailed { cwd_raw, message } => { + if app.cwd_raw != cwd_raw { + return; + } + crate::app::plugins::apply_inventory_refresh_failure(app, message); + } + ClientEvent::PluginsCliActionSucceeded { cwd_raw, result } => { + if app.cwd_raw != cwd_raw { + return; + } + crate::app::plugins::apply_cli_action_success(app, result); + } + ClientEvent::PluginsCliActionFailed { cwd_raw, message } => { + if app.cwd_raw != cwd_raw { + return; + } + crate::app::plugins::apply_cli_action_failure(app, message); + } + ClientEvent::FatalError(error) => session::handle_fatal_error_event(app, error), + } +} diff --git a/claude-code-rust/src/app/events/mod.rs b/claude-code-rust/src/app/events/mod.rs new file mode 100644 index 0000000..24f7f34 --- /dev/null +++ b/claude-code-rust/src/app/events/mod.rs @@ -0,0 +1,4048 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +mod client; +mod mouse; +mod rate_limit; +mod session; +mod session_reset; +mod streaming; +mod tool_calls; +mod tool_updates; +mod turn; + +use super::{ + ActiveView, App, AppStatus, ChatMessage, MessageBlock, MessageRole, PendingCommandAck, + SystemSeverity, TextBlock, +}; +use crate::agent::model; +use crate::app::todos::apply_plan_todos; +#[cfg(test)] +use crossterm::event::KeyEvent; +use crossterm::event::{Event, KeyEventKind}; + +pub use client::handle_client_event; + +pub fn handle_terminal_event(app: &mut App, event: Event) { + let changed = match event { + Event::Key(key) if key.kind == KeyEventKind::Press => dispatch_key_by_view(app, key), + Event::Mouse(mouse) => { + dispatch_mouse_by_view(app, mouse); + true + } + Event::Paste(text) => dispatch_paste_by_view(app, &text), + Event::FocusGained => { + app.notifications.on_focus_gained(); + app.refresh_git_branch(); + true + } + Event::FocusLost => { + app.notifications.on_focus_lost(); + true + } + Event::Resize(width, height) => { + handle_resize(app, width, height); + true + } + // Non-press key events (Release, Repeat) -- ignored. + Event::Key(_) => false, + }; + app.needs_redraw |= changed; +} + +fn handle_resize(app: &mut App, width: u16, height: u16) { + // Force a full terminal clear on resize. Without this, terminal + // emulators (especially on Windows) corrupt their scrollback buffer + // when the alternate screen is resized, causing the visible area to + // shift even though ratatui paints the correct content. The clear + // resets the terminal's internal state. + app.force_redraw = true; + + // Interaction-facing geometry is stale until the next frame computes the + // new layout. Invalidate it immediately so mouse/selection logic cannot + // keep using old hitboxes after a resize event. + app.cached_frame_area = ratatui::layout::Rect::new(0, 0, width, height); + app.rendered_chat_area = ratatui::layout::Rect::default(); + app.rendered_input_area = ratatui::layout::Rect::default(); + app.rendered_chat_lines.clear(); + app.rendered_input_lines.clear(); + app.selection = None; + app.scrollbar_drag = None; + + crate::ui::help::sync_geometry_state(app, width); +} + +fn dispatch_key_by_view(app: &mut App, key: crossterm::event::KeyEvent) -> bool { + match app.active_view { + ActiveView::Chat => { + app.active_paste_session = None; + super::keys::dispatch_key_by_focus(app, key) + } + ActiveView::Config => { + super::config::handle_key(app, key); + true + } + ActiveView::Trusted => { + super::trust::handle_key(app, key); + true + } + } +} + +fn dispatch_mouse_by_view(app: &mut App, mouse: crossterm::event::MouseEvent) { + match app.active_view { + ActiveView::Chat => { + app.active_paste_session = None; + mouse::handle_mouse_event(app, mouse); + } + ActiveView::Config | ActiveView::Trusted => { + let _ = mouse; + } + } +} + +fn dispatch_paste_by_view(app: &mut App, text: &str) -> bool { + match app.active_view { + ActiveView::Chat => { + if !matches!( + app.status, + AppStatus::Connecting | AppStatus::CommandPending | AppStatus::Error + ) && !app.is_compacting + { + app.queue_paste_text(text); + return true; + } + false + } + ActiveView::Config => super::config::handle_paste(app, text), + ActiveView::Trusted => false, + } +} + +fn handle_session_update_event(app: &mut App, update: model::SessionUpdate) { + let needs_history_retention = matches!( + &update, + model::SessionUpdate::AgentMessageChunk(_) + | model::SessionUpdate::ToolCall(_) + | model::SessionUpdate::ToolCallUpdate(_) + | model::SessionUpdate::CompactionBoundary(_) + ); + handle_session_update(app, update); + if needs_history_retention { + app.enforce_history_retention_tracked(); + } +} + +fn handle_session_update(app: &mut App, update: model::SessionUpdate) { + tracing::debug!("SessionUpdate variant: {}", session_update_name(&update)); + match update { + model::SessionUpdate::AgentMessageChunk(chunk) => { + clear_compaction_state(app, true); + streaming::handle_agent_message_chunk(app, chunk); + } + model::SessionUpdate::ToolCall(tc) => tool_calls::handle_tool_call(app, tc), + model::SessionUpdate::ToolCallUpdate(tcu) => { + tool_updates::handle_tool_call_update_session(app, &tcu); + } + model::SessionUpdate::UserMessageChunk(_) => {} + model::SessionUpdate::AgentThoughtChunk(chunk) => { + tracing::debug!("Agent thought: {:?}", chunk); + app.status = AppStatus::Thinking; + } + model::SessionUpdate::Plan(plan) => { + tracing::debug!("Plan update: {:?}", plan); + apply_plan_todos(app, &plan); + } + model::SessionUpdate::AvailableCommandsUpdate(cmds) => { + tracing::debug!("Available commands: {} commands", cmds.available_commands.len()); + app.available_commands = cmds.available_commands; + crate::app::plugins::clamp_selection(app); + if app.slash.is_some() { + super::slash::update_query(app); + } + } + model::SessionUpdate::AvailableAgentsUpdate(agents) => { + tracing::debug!("Available subagents: {} agents", agents.available_agents.len()); + app.available_agents = agents.available_agents; + if app.subagent.is_some() { + super::subagent::update_query(app); + } + } + model::SessionUpdate::ModeStateUpdate(mode) => { + app.mode = Some(mode); + if matches!(app.pending_command_ack, Some(PendingCommandAck::CurrentModeUpdate)) { + session::clear_pending_command(app); + } + } + model::SessionUpdate::CurrentModeUpdate(update) => { + let mode_id = update.current_mode_id.to_string(); + if let Some(ref mut mode) = app.mode { + if let Some(info) = mode.available_modes.iter().find(|m| m.id == mode_id) { + mode.current_mode_name.clone_from(&info.name); + mode.current_mode_id = mode_id; + } else { + mode.current_mode_name.clone_from(&mode_id); + mode.current_mode_id = mode_id; + } + } + if matches!(app.pending_command_ack, Some(PendingCommandAck::CurrentModeUpdate)) { + session::clear_pending_command(app); + } + } + model::SessionUpdate::ConfigOptionUpdate(config) => { + tracing::debug!("Config update: {:?}", config); + let option_id = config.option_id; + let value = config.value; + let model_name = + if option_id == "model" { value.as_str().map(ToOwned::to_owned) } else { None }; + app.config_options.insert(option_id.clone(), value); + + if let Some(model_name) = model_name { + app.model_name = model_name; + app.update_welcome_model_once(); + } else if option_id == "model" { + tracing::warn!("ConfigOptionUpdate for model carried non-string value"); + } + + if matches!( + app.pending_command_ack.as_ref(), + Some(PendingCommandAck::ConfigOptionUpdate { option_id: expected }) + if expected == &option_id + ) { + session::clear_pending_command(app); + } + } + model::SessionUpdate::FastModeUpdate(state) => { + app.fast_mode_state = state; + } + model::SessionUpdate::RateLimitUpdate(update) => { + rate_limit::handle_rate_limit_update(app, &update); + } + model::SessionUpdate::SessionStatusUpdate(status) => { + // TODO(runtime-verification): confirm in real SDK sessions that compaction + // status updates are emitted consistently; if not, add a fallback indicator. + if matches!(status, model::SessionStatus::Compacting) { + app.is_compacting = true; + } else { + clear_compaction_state(app, true); + } + tracing::debug!("SessionStatusUpdate: compacting={}", app.is_compacting); + } + model::SessionUpdate::CompactionBoundary(boundary) => { + rate_limit::handle_compaction_boundary_update(app, boundary); + } + } +} + +pub(crate) fn push_system_message_with_severity( + app: &mut App, + severity: Option, + message: &str, +) { + app.push_message_tracked(ChatMessage { + role: MessageRole::System(severity), + blocks: vec![MessageBlock::Text(TextBlock::from_complete(message))], + usage: None, + }); + app.enforce_history_retention_tracked(); + app.viewport.engage_auto_scroll(); +} + +pub(super) fn clear_compaction_state(app: &mut App, emit_manual_success: bool) { + if !app.is_compacting && !app.pending_compact_clear { + return; + } + let should_emit_success = emit_manual_success && app.pending_compact_clear; + app.pending_compact_clear = false; + app.is_compacting = false; + if should_emit_success { + push_system_message_with_severity( + app, + Some(SystemSeverity::Info), + "Session successfully compacted.", + ); + } +} + +/// Return a human-readable name for a `SessionUpdate` variant (for debug logging). +fn session_update_name(update: &model::SessionUpdate) -> &'static str { + match update { + model::SessionUpdate::AgentMessageChunk(_) => "AgentMessageChunk", + model::SessionUpdate::ToolCall(_) => "ToolCall", + model::SessionUpdate::ToolCallUpdate(_) => "ToolCallUpdate", + model::SessionUpdate::UserMessageChunk(_) => "UserMessageChunk", + model::SessionUpdate::AgentThoughtChunk(_) => "AgentThoughtChunk", + model::SessionUpdate::Plan(_) => "Plan", + model::SessionUpdate::AvailableCommandsUpdate(_) => "AvailableCommandsUpdate", + model::SessionUpdate::AvailableAgentsUpdate(_) => "AvailableAgentsUpdate", + model::SessionUpdate::ModeStateUpdate(_) => "ModeStateUpdate", + model::SessionUpdate::CurrentModeUpdate(_) => "CurrentModeUpdate", + model::SessionUpdate::ConfigOptionUpdate(_) => "ConfigOptionUpdate", + model::SessionUpdate::FastModeUpdate(_) => "FastModeUpdate", + model::SessionUpdate::RateLimitUpdate(_) => "RateLimitUpdate", + model::SessionUpdate::SessionStatusUpdate(_) => "SessionStatusUpdate", + model::SessionUpdate::CompactionBoundary(_) => "CompactionBoundary", + } +} + +#[cfg(test)] +fn handle_normal_key(app: &mut App, key: KeyEvent) { + super::keys::handle_normal_key(app, key); +} + +#[cfg(test)] +fn handle_mention_key(app: &mut App, key: KeyEvent) { + super::keys::handle_mention_key(app, key); +} + +#[cfg(test)] +fn dispatch_key_by_focus(app: &mut App, key: KeyEvent) { + super::keys::dispatch_key_by_focus(app, key); +} + +#[cfg(test)] +mod tests { + // ===== + // TESTS: 40 + // ===== + + use super::*; + use crate::agent::error_handling::TurnErrorClass; + use crate::agent::events::ClientEvent; + use crate::agent::events::ServiceStatusSeverity; + use crate::agent::events::TerminalProcess; + use crate::app::slash::{SlashCandidate, SlashContext, SlashState}; + use crate::app::{ + ActiveView, BlockCache, CancelOrigin, FocusOwner, FocusTarget, HelpView, InlinePermission, + SelectionKind, SelectionPoint, SelectionState, TextBlockSpacing, TodoItem, TodoStatus, + ToolCallInfo, ToolCallScope, UsageSnapshot, UsageSourceKind, mention, + }; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; + use pretty_assertions::assert_eq; + use ratatui::layout::Rect; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + use tokio::sync::oneshot; + + // Helper: build a minimal ToolCallInfo with given id + status + + fn tool_call(id: &str, status: model::ToolCallStatus) -> ToolCallInfo { + ToolCallInfo { + id: id.into(), + title: id.into(), + sdk_tool_name: "Read".into(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status, + content: vec![], + hidden: false, + terminal_id: None, + terminal_command: None, + terminal_output: None, + terminal_output_len: 0, + terminal_bytes_seen: 0, + terminal_snapshot_mode: crate::app::TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + } + } + + fn assistant_msg(blocks: Vec) -> ChatMessage { + ChatMessage { role: MessageRole::Assistant, blocks, usage: None } + } + + fn user_msg(text: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::User, + blocks: vec![MessageBlock::Text(TextBlock::from_complete(text))], + usage: None, + } + } + + // shorten_tool_title + + #[test] + fn shorten_unix_path() { + let result = tool_calls::shorten_tool_title( + "Read /home/user/project/src/main.rs", + "/home/user/project", + ); + assert_eq!(result, "Read src/main.rs"); + } + + #[test] + fn register_tool_call_scope_treats_agent_as_task_scope() { + let mut app = make_test_app(); + let scope = tool_calls::register_tool_call_scope(&mut app, "tool-agent", "Agent"); + assert_eq!(scope, ToolCallScope::Task); + assert!(app.active_task_ids.contains("tool-agent")); + } + + #[test] + fn register_tool_call_scope_treats_task_as_task_scope() { + let mut app = make_test_app(); + let scope = tool_calls::register_tool_call_scope(&mut app, "tool-task", "Task"); + assert_eq!(scope, ToolCallScope::Task); + assert!(app.active_task_ids.contains("tool-task")); + } + + /// Regression: when a Task was cancelled mid-turn, `active_task_ids` was never cleared + /// because `finalize_in_progress_tool_calls` doesn't call `remove_active_task` and + /// `clear_tool_scope_tracking` (called on `TurnComplete`) did not clear `active_task_ids`. + /// The leaked ID caused main-agent tools on the next turn to be classified as Subagent, + /// which eventually triggered the subagent thinking indicator spuriously. + #[test] + fn turn_complete_after_cancelled_task_leaves_no_stale_active_task_ids() { + let mut app = make_test_app(); + + // Simulate a Task tool call arriving as InProgress (no Completed update will follow) + let task_tc = model::ToolCall::new("task-1", "Research") + .kind(model::ToolKind::Think) + .status(model::ToolCallStatus::InProgress) + .meta(serde_json::json!({"claudeCode": {"toolName": "Task"}})); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(task_tc)), + ); + assert!(app.active_task_ids.contains("task-1"), "task must be tracked while InProgress"); + + // User cancels then TurnComplete finalizes the turn + handle_client_event(&mut app, ClientEvent::TurnCancelled); + handle_client_event(&mut app, ClientEvent::TurnComplete); + + // Stale task ID must be gone after turn boundary + assert!(app.active_task_ids.is_empty(), "stale task id must not survive TurnComplete"); + assert!(app.active_subagent_tool_ids.is_empty()); + assert!(app.subagent_idle_since.is_none()); + + // Next turn: a normal main-agent Glob must get MainAgent scope, not Subagent + let glob_tc = model::ToolCall::new("glob-1", "Glob **/*.rs") + .kind(model::ToolKind::Search) + .status(model::ToolCallStatus::InProgress) + .meta(serde_json::json!({"claudeCode": {"toolName": "Glob"}})); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(glob_tc)), + ); + assert_eq!( + app.tool_call_scope("glob-1"), + Some(ToolCallScope::MainAgent), + "main-agent tool must not be misclassified as Subagent after stale task is cleared" + ); + assert!( + app.active_subagent_tool_ids.is_empty(), + "main-agent tool must not enter subagent tracking" + ); + } + + #[test] + fn shorten_windows_path() { + let result = tool_calls::shorten_tool_title( + "Read C:\\Users\\me\\project\\src\\main.rs", + "C:\\Users\\me\\project", + ); + assert_eq!(result, "Read src/main.rs"); + } + + #[test] + fn shorten_no_match_returns_original() { + let result = + tool_calls::shorten_tool_title("Read /other/path/file.rs", "/home/user/project"); + assert_eq!(result, "Read /other/path/file.rs"); + } + + // shorten_tool_title + + #[test] + fn shorten_empty_cwd() { + let result = tool_calls::shorten_tool_title("Read /some/path/file.rs", ""); + assert_eq!(result, "Read /some/path/file.rs"); + } + + #[test] + fn shorten_cwd_with_trailing_slash() { + let result = tool_calls::shorten_tool_title( + "Read /home/user/project/file.rs", + "/home/user/project/", + ); + assert_eq!(result, "Read file.rs"); + } + + #[test] + fn shorten_title_is_just_path() { + let result = + tool_calls::shorten_tool_title("/home/user/project/file.rs", "/home/user/project"); + assert_eq!(result, "file.rs"); + } + + #[test] + fn shorten_mixed_separators() { + let result = tool_calls::shorten_tool_title( + "Read C:/Users/me/project/src/lib.rs", + "C:\\Users\\me\\project", + ); + assert_eq!(result, "Read src/lib.rs"); + } + + #[test] + fn shorten_empty_title() { + assert_eq!(tool_calls::shorten_tool_title("", "/some/cwd"), ""); + } + + #[test] + fn shorten_title_no_path_at_all() { + assert_eq!(tool_calls::shorten_tool_title("Read", "/home/user"), "Read"); + assert_eq!(tool_calls::shorten_tool_title("Write something", "/proj"), "Write something"); + } + + #[test] + fn shorten_title_equals_cwd_exactly() { + // Title IS the cwd path - after stripping, nothing left + let result = tool_calls::shorten_tool_title("/home/user/project", "/home/user/project"); + // The cwd+/ won't match because title doesn't have trailing content after cwd + // cwd_norm = "/home/user/project/", title doesn't contain that + assert_eq!(result, "/home/user/project"); + } + + // shorten_tool_title + + #[test] + fn shorten_partial_match_no_false_positive() { + let result = tool_calls::shorten_tool_title("Read /home/username/file.rs", "/home/user"); + assert_eq!(result, "Read /home/username/file.rs"); + } + + #[test] + fn shorten_deeply_nested_path() { + let cwd = "/a/b/c/d/e/f/g"; + let title = "Read /a/b/c/d/e/f/g/h/i/j.rs"; + let result = tool_calls::shorten_tool_title(title, cwd); + assert_eq!(result, "Read h/i/j.rs"); + } + + #[test] + fn shorten_cwd_appears_multiple_times() { + let result = tool_calls::shorten_tool_title("Diff /proj/a.rs /proj/b.rs", "/proj"); + assert_eq!(result, "Diff a.rs b.rs"); + } + + /// Spaces in path (real Windows path with spaces). + #[test] + fn shorten_spaces_in_path() { + let result = tool_calls::shorten_tool_title( + "Read C:\\Users\\Simon Peter Rothgang\\Desktop\\project\\src\\main.rs", + "C:\\Users\\Simon Peter Rothgang\\Desktop\\project", + ); + assert_eq!(result, "Read src/main.rs"); + } + + /// Unicode characters in path components. + #[test] + fn shorten_unicode_in_path() { + let result = tool_calls::shorten_tool_title( + "Read /home/\u{00FC}ser/\u{30D7}\u{30ED}\u{30B8}\u{30A7}\u{30AF}\u{30C8}/src/lib.rs", + "/home/\u{00FC}ser/\u{30D7}\u{30ED}\u{30B8}\u{30A7}\u{30AF}\u{30C8}", + ); + assert_eq!(result, "Read src/lib.rs"); + } + + /// Root as cwd (Unix). + #[test] + fn shorten_cwd_is_root_unix() { + // cwd = "/" => with_sep = "/", so "/foo/bar.rs".contains("/") => replaces + let result = tool_calls::shorten_tool_title("Read /foo/bar.rs", "/"); + // "/" is first path component = "" (empty), heuristic check uses "" which is in everything + // After normalization: cwd = "/", with_sep = "/", title contains "/" => replaces ALL "/" + assert_eq!(result, "Read foobar.rs"); + } + + /// Root as cwd (Windows). + #[test] + fn shorten_cwd_is_drive_root_windows() { + let result = tool_calls::shorten_tool_title("Read C:\\src\\main.rs", "C:\\"); + assert_eq!(result, "Read src/main.rs"); + } + + /// Very long path (stress test). + #[test] + fn shorten_very_long_path() { + let segments: String = (0..50).fold(String::new(), |mut s, i| { + use std::fmt::Write; + write!(s, "/seg{i}").unwrap(); + s + }); + let cwd = segments.clone(); + let title = format!("Read {segments}/deep/file.rs"); + let result = tool_calls::shorten_tool_title(&title, &cwd); + assert_eq!(result, "Read deep/file.rs"); + } + + /// Case sensitivity: paths are case-sensitive. + #[test] + fn shorten_case_sensitive() { + let result = + tool_calls::shorten_tool_title("Read /Home/User/Project/file.rs", "/home/user/project"); + // Different case, so the first-component heuristic "home" matches "Home"? + // No: cwd_start = "home", title doesn't contain "home" (has "Home") => early return + assert_eq!(result, "Read /Home/User/Project/file.rs"); + } + + /// Cwd that is a prefix at directory boundary but not at cwd boundary. + #[test] + fn shorten_cwd_prefix_boundary() { + // cwd="/pro" should NOT strip from "/project/file.rs" + let result = tool_calls::shorten_tool_title("Read /project/file.rs", "/pro"); + // cwd_start = "pro", title contains "pro" (in "project") => proceeds to normalize + // with_sep = "/pro/", title_norm = "Read /project/file.rs", doesn't contain "/pro/" + assert_eq!(result, "Read /project/file.rs"); + } + + #[test] + fn split_index_prefers_double_newline() { + let text = "first\n\nsecond"; + let split_at = streaming::find_text_block_split_index(text); + assert_eq!(split_at, Some("first\n\n".len())); + } + + #[test] + fn split_index_soft_limit_prefers_newline() { + use super::super::default_cache_split_policy; + let prefix = "a".repeat(default_cache_split_policy().soft_limit_bytes - 1); + let text = format!("{prefix}\n{}", "b".repeat(32)); + let split_at = streaming::find_text_block_split_index(&text).expect("expected split index"); + assert_eq!(&text[..split_at], format!("{prefix}\n")); + } + + #[test] + fn split_index_hard_limit_uses_sentence_when_needed() { + use super::super::default_cache_split_policy; + let prefix = "a".repeat(default_cache_split_policy().hard_limit_bytes + 32); + let text = format!("{prefix}. tail"); + let split_at = streaming::find_text_block_split_index(&text).expect("expected split index"); + assert_eq!(&text[..split_at], format!("{prefix}.")); + } + + #[test] + fn split_index_ignores_double_newline_inside_code_fence() { + let text = "```\nline1\n\nline2\n```"; + assert!(streaming::find_text_block_split_index(text).is_none()); + } + + #[test] + fn agent_message_chunk_splits_into_frozen_text_blocks() { + let mut app = make_test_app(); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::AgentMessageChunk( + model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new( + "p1\n\np2\n\np3", + ))), + )), + ); + + assert_eq!(app.messages.len(), 1); + let Some(last) = app.messages.last() else { + panic!("missing assistant message"); + }; + assert!(matches!(last.role, MessageRole::Assistant)); + assert_eq!(last.blocks.len(), 3); + let Some(MessageBlock::Text(b1)) = last.blocks.first() else { + panic!("expected first text block"); + }; + let Some(MessageBlock::Text(b2)) = last.blocks.get(1) else { + panic!("expected second text block"); + }; + let Some(MessageBlock::Text(b3)) = last.blocks.get(2) else { + panic!("expected third text block"); + }; + assert_eq!(b1.text, "p1\n\n"); + assert_eq!(b2.text, "p2\n\n"); + assert_eq!(b3.text, "p3"); + assert_eq!(b1.trailing_spacing, TextBlockSpacing::ParagraphBreak); + assert_eq!(b2.trailing_spacing, TextBlockSpacing::ParagraphBreak); + assert_eq!(b3.trailing_spacing, TextBlockSpacing::None); + } + + // has_in_progress_tool_calls + + fn make_test_app() -> App { + App::test_default() + } + + fn connected_event(model_name: &str) -> ClientEvent { + ClientEvent::Connected { + session_id: model::SessionId::new("test-session"), + cwd: "/test".into(), + model_name: model_name.to_owned(), + available_models: Vec::new(), + mode: None, + history_updates: Vec::new(), + } + } + + fn app_with_bridge_connection() + -> (App, tokio::sync::mpsc::UnboundedReceiver) { + let mut app = make_test_app(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(Rc::new(crate::agent::client::AgentConnection::new(tx))); + (app, rx) + } + + #[test] + fn raw_output_string_maps_to_terminal_text() { + let raw = serde_json::json!("hello\nworld"); + assert_eq!( + tool_updates::raw_output_to_terminal_text(&raw).as_deref(), + Some("hello\nworld") + ); + } + + #[test] + fn raw_output_text_array_maps_to_terminal_text() { + let raw = serde_json::json!([ + {"type": "text", "text": "first"}, + {"type": "text", "text": "second"} + ]); + assert_eq!( + tool_updates::raw_output_to_terminal_text(&raw).as_deref(), + Some("first\nsecond") + ); + } + + #[test] + fn execute_tool_update_uses_raw_output_fallback() { + let mut app = make_test_app(); + let tc = model::ToolCall::new("tc-exec", "Terminal") + .kind(model::ToolKind::Execute) + .status(model::ToolCallStatus::InProgress); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(tc)), + ); + + let fields = model::ToolCallUpdateFields::new() + .status(model::ToolCallStatus::Completed) + .raw_output(serde_json::json!("line 1\nline 2")); + let update = model::ToolCallUpdate::new("tc-exec", fields); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCallUpdate(update)), + ); + + let Some((mi, bi)) = app.lookup_tool_call("tc-exec") else { + panic!("tool call not indexed"); + }; + let Some(MessageBlock::ToolCall(tc)) = app.messages.get(mi).and_then(|m| m.blocks.get(bi)) + else { + panic!("tool call block missing"); + }; + assert_eq!(tc.terminal_output.as_deref(), Some("line 1\nline 2")); + } + + #[test] + fn tool_call_update_with_same_terminal_content_still_invalidates_command_changes() { + let mut app = make_test_app(); + let tc = model::ToolCall::new("tc-exec-terminal", "Terminal") + .kind(model::ToolKind::Execute) + .status(model::ToolCallStatus::InProgress) + .content(vec![model::ToolCallContent::Terminal(model::TerminalToolCallContent::new( + "term-1", + ))]); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(tc)), + ); + + app.terminals.borrow_mut().insert( + "term-1".to_owned(), + TerminalProcess { + child: None, + output_buffer: Arc::new(Mutex::new(Vec::new())), + command: "echo refreshed".to_owned(), + }, + ); + + let (mi, bi) = app.lookup_tool_call("tc-exec-terminal").expect("tool call not indexed"); + let before_layout = match &app.messages[mi].blocks[bi] { + MessageBlock::ToolCall(tc) => tc.layout_epoch, + _ => panic!("expected tool call block"), + }; + + let update = model::ToolCallUpdate::new( + "tc-exec-terminal", + model::ToolCallUpdateFields::new().content(vec![model::ToolCallContent::Terminal( + model::TerminalToolCallContent::new("term-1"), + )]), + ); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCallUpdate(update)), + ); + + let MessageBlock::ToolCall(tc) = &app.messages[mi].blocks[bi] else { + panic!("expected tool call block"); + }; + assert_eq!(tc.terminal_command.as_deref(), Some("echo refreshed")); + assert!(tc.layout_epoch > before_layout); + assert_eq!(app.viewport.oldest_stale_index(), Some(mi)); + } + + #[test] + fn late_tool_update_for_removed_tool_does_not_corrupt_active_subagent_set() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "tool-stale", + model::ToolCallStatus::Completed, + )))])); + app.index_tool_call("tool-stale".into(), 0, 0); + app.register_tool_call_scope("tool-stale".into(), ToolCallScope::Subagent); + + let removed = app.remove_message_tracked(0); + assert!(removed.is_some()); + assert_eq!(app.tool_call_scope("tool-stale"), None); + + let update = model::ToolCallUpdate::new( + "tool-stale", + model::ToolCallUpdateFields::new().status(model::ToolCallStatus::InProgress), + ); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCallUpdate(update)), + ); + + assert!(app.active_subagent_tool_ids.is_empty()); + assert!(app.active_task_ids.is_empty()); + } + + #[test] + fn repeated_tool_call_updates_existing_execute_snapshot_state() { + let mut app = make_test_app(); + app.terminals.borrow_mut().insert( + "term-2".to_owned(), + TerminalProcess { + child: None, + output_buffer: Arc::new(Mutex::new(Vec::new())), + command: "echo second".to_owned(), + }, + ); + + let first = model::ToolCall::new("tc-dup", "Terminal") + .kind(model::ToolKind::Execute) + .status(model::ToolCallStatus::InProgress) + .content(vec![model::ToolCallContent::Terminal(model::TerminalToolCallContent::new( + "term-1", + ))]) + .raw_output(serde_json::json!("first")); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(first)), + ); + + let second = model::ToolCall::new("tc-dup", "Terminal") + .kind(model::ToolKind::Execute) + .status(model::ToolCallStatus::InProgress) + .content(vec![model::ToolCallContent::Terminal(model::TerminalToolCallContent::new( + "term-2", + ))]) + .raw_output(serde_json::json!("second")); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(second)), + ); + + let (mi, bi) = app.lookup_tool_call("tc-dup").expect("tool call not indexed"); + let MessageBlock::ToolCall(tc) = &app.messages[mi].blocks[bi] else { + panic!("expected tool call block"); + }; + assert_eq!(tc.terminal_output.as_deref(), Some("second")); + assert_eq!(tc.terminal_id.as_deref(), Some("term-2")); + assert_eq!(tc.terminal_command.as_deref(), Some("echo second")); + assert!(app.terminal_tool_calls.iter().any(|entry| entry.terminal_id == "term-2" + && entry.msg_idx == mi + && entry.block_idx == bi)); + assert!(app.terminal_tool_calls.iter().all(|entry| entry.terminal_id != "term-1")); + } + + #[test] + fn tool_call_update_noop_does_not_bump_epochs() { + let mut app = make_test_app(); + let tc = model::ToolCall::new("tc-noop", "Read file") + .kind(model::ToolKind::Read) + .status(model::ToolCallStatus::InProgress); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(tc)), + ); + + let (mi, bi) = app.lookup_tool_call("tc-noop").expect("tool call not indexed"); + let (before_render, before_layout, before_oldest_stale) = { + let MessageBlock::ToolCall(tc) = &app.messages[mi].blocks[bi] else { + panic!("tool call block missing"); + }; + (tc.render_epoch, tc.layout_epoch, app.viewport.oldest_stale_index()) + }; + + let update = model::ToolCallUpdate::new( + "tc-noop", + model::ToolCallUpdateFields::new().status(model::ToolCallStatus::InProgress), + ); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCallUpdate(update)), + ); + + let MessageBlock::ToolCall(tc) = &app.messages[mi].blocks[bi] else { + panic!("tool call block missing"); + }; + assert_eq!(tc.render_epoch, before_render); + assert_eq!(tc.layout_epoch, before_layout); + assert_eq!(app.viewport.oldest_stale_index(), before_oldest_stale); + } + + #[test] + fn todowrite_tool_call_without_todos_array_preserves_existing_todos() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Existing todo".into(), + status: TodoStatus::InProgress, + active_form: String::new(), + }); + app.show_todo_panel = true; + + let todo_call = model::ToolCall::new("tc-todo-empty", "TodoWrite") + .kind(model::ToolKind::Other) + .raw_input(serde_json::json!({})) + .meta(serde_json::json!({"claudeCode": {"toolName": "TodoWrite"}})); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(todo_call)), + ); + + assert_eq!(app.todos.len(), 1); + assert_eq!(app.todos[0].content, "Existing todo"); + assert_eq!(app.todos[0].status, TodoStatus::InProgress); + assert!(app.show_todo_panel); + } + + #[test] + fn todowrite_tool_call_update_without_todos_array_preserves_existing_todos() { + let mut app = make_test_app(); + let todo_call = model::ToolCall::new("tc-todo-update", "TodoWrite") + .kind(model::ToolKind::Other) + .raw_input(serde_json::json!({ + "todos": [{"content": "Task A", "status": "in_progress"}] + })) + .meta(serde_json::json!({"claudeCode": {"toolName": "TodoWrite"}})); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCall(todo_call)), + ); + assert_eq!(app.todos.len(), 1); + assert_eq!(app.todos[0].content, "Task A"); + + let update = model::ToolCallUpdate::new( + "tc-todo-update", + model::ToolCallUpdateFields::new().raw_input(serde_json::json!({})), + ); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ToolCallUpdate(update)), + ); + + assert_eq!(app.todos.len(), 1); + assert_eq!(app.todos[0].content, "Task A"); + assert_eq!(app.todos[0].status, TodoStatus::InProgress); + } + + #[test] + fn has_in_progress_empty_messages() { + let app = make_test_app(); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + #[test] + fn has_in_progress_no_tool_calls() { + let mut app = make_test_app(); + app.messages + .push(assistant_msg(vec![MessageBlock::Text(TextBlock::from_complete("hello"))])); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + #[test] + fn has_in_progress_with_pending_tool() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "tc1", + model::ToolCallStatus::Pending, + )))])); + app.bind_active_turn_assistant_to_tail(); + assert!(tool_calls::has_in_progress_tool_calls(&app)); + } + + #[test] + fn has_in_progress_with_in_progress_tool() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "tc1", + model::ToolCallStatus::InProgress, + )))])); + app.bind_active_turn_assistant_to_tail(); + assert!(tool_calls::has_in_progress_tool_calls(&app)); + } + + #[test] + fn has_in_progress_all_completed() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "tc1", + model::ToolCallStatus::Completed, + )))])); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + #[test] + fn has_in_progress_all_failed() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "tc1", + model::ToolCallStatus::Failed, + )))])); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + // has_in_progress_tool_calls + + #[test] + fn has_in_progress_user_message_last() { + let mut app = make_test_app(); + app.messages.push(user_msg("hi")); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + /// Without an explicit owner, in-progress tools do not count even if the last assistant has them. + #[test] + fn has_in_progress_requires_explicit_owner() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "tc1", + model::ToolCallStatus::InProgress, + )))])); + app.messages.push(user_msg("thanks")); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + /// The owned assistant decides the result even when another assistant trails later. + #[test] + fn has_in_progress_uses_owned_assistant_not_latest_assistant() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "tc1", + model::ToolCallStatus::InProgress, + )))])); + app.messages.push(user_msg("ok")); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "tc2", + model::ToolCallStatus::Completed, + )))])); + app.bind_active_turn_assistant(0); + assert!(tool_calls::has_in_progress_tool_calls(&app)); + } + + #[test] + fn has_in_progress_mixed_completed_and_pending() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![ + MessageBlock::ToolCall(Box::new(tool_call("tc1", model::ToolCallStatus::Completed))), + MessageBlock::ToolCall(Box::new(tool_call("tc2", model::ToolCallStatus::InProgress))), + ])); + app.bind_active_turn_assistant_to_tail(); + assert!(tool_calls::has_in_progress_tool_calls(&app)); + } + + /// Text blocks mixed with tool calls - text blocks are correctly skipped. + #[test] + fn has_in_progress_text_and_tools_mixed() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![ + MessageBlock::Text(TextBlock::from_complete("thinking...")), + MessageBlock::ToolCall(Box::new(tool_call("tc1", model::ToolCallStatus::Completed))), + MessageBlock::Text(TextBlock::from_complete("done")), + ])); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + /// Stress: 100 completed tool calls + 1 pending at the end. + #[test] + fn has_in_progress_stress_100_tools_one_pending() { + let mut app = make_test_app(); + let mut blocks: Vec = (0..100) + .map(|i| { + MessageBlock::ToolCall(Box::new(tool_call( + &format!("tc{i}"), + model::ToolCallStatus::Completed, + ))) + }) + .collect(); + blocks.push(MessageBlock::ToolCall(Box::new(tool_call( + "tc_pending", + model::ToolCallStatus::Pending, + )))); + app.messages.push(assistant_msg(blocks)); + app.bind_active_turn_assistant_to_tail(); + assert!(tool_calls::has_in_progress_tool_calls(&app)); + } + + /// Stress: 100 completed tool calls, none pending. + #[test] + fn has_in_progress_stress_100_tools_all_done() { + let mut app = make_test_app(); + let blocks: Vec = (0..100) + .map(|i| { + MessageBlock::ToolCall(Box::new(tool_call( + &format!("tc{i}"), + model::ToolCallStatus::Completed, + ))) + }) + .collect(); + app.messages.push(assistant_msg(blocks)); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + /// Mix of Failed and Completed - neither counts as in-progress. + #[test] + fn has_in_progress_failed_and_completed_mix() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![ + MessageBlock::ToolCall(Box::new(tool_call("tc1", model::ToolCallStatus::Completed))), + MessageBlock::ToolCall(Box::new(tool_call("tc2", model::ToolCallStatus::Failed))), + MessageBlock::ToolCall(Box::new(tool_call("tc3", model::ToolCallStatus::Completed))), + ])); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + /// Empty assistant message (no blocks at all). + #[test] + fn has_in_progress_empty_assistant_blocks() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![])); + assert!(!tool_calls::has_in_progress_tool_calls(&app)); + } + + // make_test_app - verify defaults + + #[test] + fn test_app_defaults() { + let app = make_test_app(); + assert!(app.messages.is_empty()); + assert_eq!(app.viewport.scroll_offset, 0); + assert_eq!(app.viewport.scroll_target, 0); + assert!(app.viewport.auto_scroll); + assert!(!app.should_quit); + assert!(app.session_id.is_none()); + assert_eq!(app.files_accessed, 0); + assert!(app.pending_interaction_ids.is_empty()); + assert!(!app.tools_collapsed); + assert!(!app.force_redraw); + assert!(app.todos.is_empty()); + assert!(!app.show_todo_panel); + assert!(app.selection.is_none()); + assert!(app.mention.is_none()); + assert!(!app.cancelled_turn_pending_hint); + assert!(app.rendered_chat_lines.is_empty()); + assert!(app.rendered_input_lines.is_empty()); + assert!(matches!(app.status, AppStatus::Ready)); + } + + #[test] + fn turn_complete_after_cancel_renders_interrupted_hint() { + let mut app = make_test_app(); + + handle_client_event(&mut app, ClientEvent::TurnCancelled); + assert!(app.cancelled_turn_pending_hint); + + handle_client_event(&mut app, ClientEvent::TurnComplete); + + assert!(!app.cancelled_turn_pending_hint); + let last = app.messages.last().expect("expected interruption hint message"); + assert!(matches!(last.role, MessageRole::System(Some(SystemSeverity::Info)))); + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Conversation interrupted. Tell the model how to proceed."); + } + + #[test] + fn turn_complete_after_manual_cancel_marks_tail_assistant_layout_dirty() { + let mut app = make_test_app(); + app.status = AppStatus::Thinking; + app.messages.push(user_msg("build app")); + app.messages.push(assistant_msg(vec![MessageBlock::Text(TextBlock::from_complete( + "partial output", + ))])); + app.pending_cancel_origin = Some(CancelOrigin::Manual); + + handle_client_event(&mut app, ClientEvent::TurnComplete); + + assert!(matches!(app.status, AppStatus::Ready)); + assert!(!app.viewport.message_height_is_current(1)); + let Some(last) = app.messages.last() else { + panic!("expected interruption hint message"); + }; + assert!(matches!(last.role, MessageRole::System(Some(SystemSeverity::Info)))); + } + + #[test] + fn turn_complete_after_auto_cancel_marks_tail_assistant_layout_dirty() { + let mut app = make_test_app(); + app.status = AppStatus::Running; + app.messages.push(user_msg("build app")); + app.messages.push(assistant_msg(vec![MessageBlock::Text(TextBlock::from_complete( + "partial output", + ))])); + app.pending_cancel_origin = Some(CancelOrigin::AutoQueue); + + handle_client_event(&mut app, ClientEvent::TurnComplete); + + assert!(matches!(app.status, AppStatus::Ready)); + assert!(!app.viewport.message_height_is_current(1)); + let Some(last) = app.messages.last() else { + panic!("expected assistant message"); + }; + assert!(matches!(last.role, MessageRole::Assistant)); + } + + #[test] + fn connected_updates_welcome_model_while_pristine() { + let mut app = make_test_app(); + app.welcome_model_resolved = false; + app.messages.push(ChatMessage::welcome("Connecting...", "/test")); + + handle_client_event(&mut app, connected_event("claude-updated")); + + let Some(first) = app.messages.first() else { + panic!("missing welcome message"); + }; + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first() else { + panic!("expected welcome block"); + }; + assert_eq!(welcome.model_name, "claude-updated"); + } + + #[test] + fn connected_updates_welcome_to_default_for_provisional_default_model() { + let mut app = make_test_app(); + app.welcome_model_resolved = false; + app.messages.push(ChatMessage::welcome("Connecting...", "/test")); + + handle_client_event(&mut app, connected_event("default")); + + let Some(first) = app.messages.first() else { + panic!("missing welcome message"); + }; + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first() else { + panic!("expected welcome block"); + }; + assert_eq!(welcome.model_name, "default"); + assert!(!app.welcome_model_resolved); + } + + #[test] + fn connected_requests_mcp_snapshot_even_outside_mcp_tab() { + let (mut app, mut rx) = app_with_bridge_connection(); + app.config.active_tab = crate::app::config::ConfigTab::Status; + app.mcp.servers.push(crate::agent::types::McpServerStatus { + name: "supabase".into(), + status: crate::agent::types::McpServerConnectionStatus::Connected, + server_info: None, + error: None, + config: None, + scope: None, + tools: Vec::new(), + }); + + handle_client_event(&mut app, connected_event("claude-updated")); + + let envelope = rx.try_recv().expect("mcp snapshot command"); + assert_eq!( + envelope.command, + crate::agent::wire::BridgeCommand::GetMcpSnapshot { + session_id: "test-session".to_owned(), + } + ); + assert!(app.mcp.in_flight); + assert!(app.mcp.servers.is_empty()); + } + + #[test] + fn connected_updates_cwd_and_clears_resuming_marker() { + let mut app = make_test_app(); + app.welcome_model_resolved = false; + app.messages.push(ChatMessage::welcome("Connecting...", "/test")); + app.resuming_session_id = Some("resume-123".into()); + + handle_client_event( + &mut app, + ClientEvent::Connected { + session_id: model::SessionId::new("session-cwd"), + cwd: "/changed".into(), + model_name: "claude-updated".into(), + available_models: Vec::new(), + mode: None, + history_updates: Vec::new(), + }, + ); + + assert_eq!(app.cwd_raw, "/changed"); + assert_eq!(app.cwd, "/changed"); + assert!(app.resuming_session_id.is_none()); + let Some(first) = app.messages.first() else { + panic!("missing welcome message"); + }; + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first() else { + panic!("expected welcome block"); + }; + assert_eq!(welcome.cwd, "/changed"); + } + + #[test] + fn connected_reconciles_trust_for_new_cwd() { + let mut app = make_test_app(); + app.trust.status = crate::app::trust::TrustStatus::Trusted; + app.config.committed_preferences_document = serde_json::json!({ + "projects": {} + }); + + handle_client_event( + &mut app, + ClientEvent::Connected { + session_id: model::SessionId::new("session-trust"), + cwd: "/untrusted".into(), + model_name: "claude-updated".into(), + available_models: Vec::new(), + mode: None, + history_updates: Vec::new(), + }, + ); + + assert_eq!(app.trust.status, crate::app::trust::TrustStatus::Untrusted); + assert_eq!( + app.trust.project_key, + crate::app::trust::store::normalize_project_key(std::path::Path::new("/untrusted")) + ); + } + + #[test] + fn connected_updates_welcome_once_even_after_chat_started() { + let mut app = make_test_app(); + app.welcome_model_resolved = false; + app.messages.push(ChatMessage::welcome("Connecting...", "/test")); + app.messages.push(user_msg("hello")); + + handle_client_event(&mut app, connected_event("claude-updated")); + + let Some(first) = app.messages.first() else { + panic!("missing first message"); + }; + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first() else { + panic!("expected welcome block"); + }; + assert_eq!(welcome.model_name, "claude-updated"); + assert!(app.welcome_model_resolved); + } + + #[test] + fn persisted_model_change_reopens_welcome_model_reconciliation() { + let mut app = make_test_app(); + app.session_id = Some(model::SessionId::new("session-1")); + app.model_name = "default".into(); + app.messages = vec![ChatMessage::welcome("default", "/test")]; + crate::app::config::store::set_model( + &mut app.config.committed_settings_document, + Some("default"), + ); + + app.update_welcome_model_once(); + assert!(app.welcome_model_resolved); + + crate::app::config::store::set_model( + &mut app.config.committed_settings_document, + Some("haiku"), + ); + app.reconcile_runtime_from_persisted_settings_change(); + assert!(!app.welcome_model_resolved); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ConfigOptionUpdate( + model::ConfigOptionUpdate { + option_id: "model".into(), + value: serde_json::Value::String("claude-opus-4-6".into()), + }, + )), + ); + + let Some(MessageBlock::Welcome(welcome)) = app.messages[0].blocks.first() else { + panic!("expected welcome block"); + }; + assert_eq!(welcome.model_name, "claude-opus-4-6"); + assert!(app.welcome_model_resolved); + } + + #[test] + fn connected_resets_session_scoped_view_data() { + let mut app = make_test_app(); + app.messages.push(user_msg("hello")); + app.status = AppStatus::Running; + app.files_accessed = 9; + app.usage.snapshot = Some(UsageSnapshot { + source: UsageSourceKind::Oauth, + fetched_at: std::time::SystemTime::now(), + five_hour: None, + seven_day: None, + seven_day_opus: None, + seven_day_sonnet: None, + extra_usage: None, + }); + app.account_info = Some(crate::agent::types::AccountInfo { + email: Some("old@example.com".into()), + organization: None, + subscription_type: None, + token_source: None, + api_key_source: None, + }); + app.plugins.installed.push(crate::app::plugins::InstalledPluginEntry { + id: "old-plugin".into(), + version: None, + scope: "user".into(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: None, + capability: crate::app::plugins::PluginCapability::Skill, + }); + app.plugins.last_inventory_refresh_at = Some(Instant::now()); + app.config.pending_session_title_change = + Some(crate::app::config::PendingSessionTitleChangeState { + session_id: "old-session".into(), + kind: crate::app::config::PendingSessionTitleChangeKind::Generate, + }); + + handle_client_event(&mut app, connected_event("claude-updated")); + + assert!(matches!(app.status, AppStatus::Ready)); + assert_eq!(app.messages.len(), 1); + assert!(matches!(app.messages[0].role, MessageRole::Welcome)); + assert_eq!(app.files_accessed, 0); + assert!(app.usage.snapshot.is_none()); + assert!(app.account_info.is_none()); + assert!(app.plugins.installed.is_empty()); + assert!(app.plugins.last_inventory_refresh_at.is_none()); + assert!(app.config.pending_session_title_change.is_none()); + } + + #[test] + fn model_config_update_resolves_welcome_once_after_provisional_default() { + let mut app = make_test_app(); + app.model_name = "default".into(); + app.welcome_model_resolved = false; + app.messages.push(ChatMessage::welcome("Connecting...", "/test")); + app.messages.push(user_msg("hello")); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ConfigOptionUpdate( + model::ConfigOptionUpdate { + option_id: "model".into(), + value: serde_json::Value::String("claude-opus-4-6".into()), + }, + )), + ); + + let Some(first) = app.messages.first() else { + panic!("missing first message"); + }; + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first() else { + panic!("expected welcome block"); + }; + assert_eq!(welcome.model_name, "claude-opus-4-6"); + assert!(app.welcome_model_resolved); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ConfigOptionUpdate( + model::ConfigOptionUpdate { + option_id: "model".into(), + value: serde_json::Value::String("claude-sonnet-4-5".into()), + }, + )), + ); + + let Some(first) = app.messages.first() else { + panic!("missing first message"); + }; + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first() else { + panic!("expected welcome block"); + }; + assert_eq!(welcome.model_name, "claude-opus-4-6"); + } + + #[test] + fn auth_required_sets_hint_without_prefilling_login_command() { + let mut app = make_test_app(); + app.input.set_text("keep me"); + + handle_client_event( + &mut app, + ClientEvent::AuthRequired { + method_name: "oauth".into(), + method_description: "Open browser".into(), + }, + ); + + assert!(matches!(app.status, AppStatus::Ready)); + assert_eq!(app.input.text(), "keep me"); + let Some(hint) = &app.login_hint else { + panic!("expected login hint"); + }; + assert_eq!(hint.method_name, "oauth"); + assert_eq!(hint.method_description, "Open browser"); + } + + #[test] + fn update_available_sets_footer_hint() { + let mut app = make_test_app(); + assert!(app.update_check_hint.is_none()); + + handle_client_event( + &mut app, + ClientEvent::UpdateAvailable { + latest_version: "0.3.0".into(), + current_version: "0.2.0".into(), + }, + ); + + assert_eq!( + app.update_check_hint.as_deref(), + Some("Update available: v0.3.0 (current v0.2.0) Ctrl+U to hide") + ); + } + + #[test] + fn service_status_warning_pushes_system_warning_without_locking_input() { + let mut app = make_test_app(); + + handle_client_event( + &mut app, + ClientEvent::ServiceStatus { + severity: ServiceStatusSeverity::Warning, + message: "Claude Code status: Partial Outage (indicator: minor).".into(), + }, + ); + + assert!(matches!(app.status, AppStatus::Ready)); + let Some(last) = app.messages.last() else { + panic!("expected system message"); + }; + assert!(matches!(last.role, MessageRole::System(Some(SystemSeverity::Warning)))); + } + + #[test] + fn service_status_error_pushes_system_error_without_locking_input() { + let mut app = make_test_app(); + app.input.set_text("draft stays"); + + handle_client_event( + &mut app, + ClientEvent::ServiceStatus { + severity: ServiceStatusSeverity::Error, + message: "Claude Code status: Major Outage (indicator: major).".into(), + }, + ); + + assert!(matches!(app.status, AppStatus::Ready)); + assert_eq!(app.input.text(), "draft stays"); + let Some(last) = app.messages.last() else { + panic!("expected system message"); + }; + assert!(matches!(last.role, MessageRole::System(Some(SystemSeverity::Error)))); + } + + #[test] + fn session_replaced_resets_chat_and_transient_state() { + let mut app = make_test_app(); + app.messages.push(user_msg("hello")); + app.messages + .push(assistant_msg(vec![MessageBlock::Text(TextBlock::from_complete("world"))])); + app.status = AppStatus::Running; + app.files_accessed = 9; + app.pending_interaction_ids.push("perm-1".into()); + app.todo_selected = 2; + app.show_todo_panel = true; + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::InProgress, + active_form: String::new(), + }); + app.mention = Some(mention::MentionState::new(0, 0, String::new(), Vec::new())); + app.mcp.servers.push(crate::agent::types::McpServerStatus { + name: "supabase".into(), + status: crate::agent::types::McpServerConnectionStatus::Connected, + server_info: None, + error: None, + config: None, + scope: None, + tools: Vec::new(), + }); + + handle_client_event( + &mut app, + ClientEvent::SessionReplaced { + session_id: model::SessionId::new("replacement"), + cwd: "/replacement".into(), + model_name: "new-model".into(), + available_models: Vec::new(), + mode: None, + history_updates: Vec::new(), + }, + ); + + assert!(matches!(app.status, AppStatus::Ready)); + assert_eq!( + app.session_id.as_ref().map(ToString::to_string).as_deref(), + Some("replacement") + ); + assert_eq!(app.model_name, "new-model"); + assert_eq!(app.messages.len(), 1); + assert!(matches!(app.messages[0].role, MessageRole::Welcome)); + assert_eq!(app.files_accessed, 0); + assert!(app.pending_interaction_ids.is_empty()); + assert!(app.todos.is_empty()); + assert!(!app.show_todo_panel); + assert!(app.mention.is_none()); + assert!(app.mcp.servers.is_empty()); + assert_eq!(app.cwd_raw, "/replacement"); + assert_eq!(app.cwd, "/replacement"); + let Some(MessageBlock::Welcome(welcome)) = app.messages[0].blocks.first() else { + panic!("expected welcome block"); + }; + assert_eq!(welcome.cwd, "/replacement"); + } + + #[test] + fn session_replaced_requests_mcp_snapshot_even_outside_mcp_tab() { + let (mut app, mut rx) = app_with_bridge_connection(); + app.config.active_tab = crate::app::config::ConfigTab::Status; + app.mcp.servers.push(crate::agent::types::McpServerStatus { + name: "supabase".into(), + status: crate::agent::types::McpServerConnectionStatus::Connected, + server_info: None, + error: None, + config: None, + scope: None, + tools: Vec::new(), + }); + + handle_client_event( + &mut app, + ClientEvent::SessionReplaced { + session_id: model::SessionId::new("replacement"), + cwd: "/replacement".into(), + model_name: "new-model".into(), + available_models: Vec::new(), + mode: None, + history_updates: Vec::new(), + }, + ); + + let envelope = rx.try_recv().expect("mcp snapshot command"); + assert_eq!( + envelope.command, + crate::agent::wire::BridgeCommand::GetMcpSnapshot { + session_id: "replacement".to_owned(), + } + ); + assert!(app.mcp.in_flight); + assert!(app.mcp.servers.is_empty()); + } + + #[test] + fn connected_requests_status_snapshot_when_status_tab_is_open() { + let (mut app, mut rx) = app_with_bridge_connection(); + app.active_view = ActiveView::Config; + app.config.active_tab = crate::app::config::ConfigTab::Status; + + handle_client_event(&mut app, connected_event("claude-updated")); + + let status = rx.try_recv().expect("status snapshot command"); + assert_eq!( + status.command, + crate::agent::wire::BridgeCommand::GetStatusSnapshot { + session_id: "test-session".to_owned(), + } + ); + let mcp = rx.try_recv().expect("mcp snapshot command"); + assert_eq!( + mcp.command, + crate::agent::wire::BridgeCommand::GetMcpSnapshot { + session_id: "test-session".to_owned(), + } + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn connected_requests_usage_refresh_when_usage_tab_is_open() { + tokio::task::LocalSet::new() + .run_until(async { + let mut app = make_test_app(); + app.active_view = ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Usage; + + handle_client_event(&mut app, connected_event("claude-updated")); + + assert!(app.usage.in_flight); + }) + .await; + } + + #[test] + fn stale_status_snapshot_for_old_session_is_ignored() { + let mut app = make_test_app(); + app.session_id = Some(model::SessionId::new("current-session")); + + handle_client_event( + &mut app, + ClientEvent::StatusSnapshotReceived { + session_id: "old-session".into(), + account: crate::agent::types::AccountInfo { + email: Some("old@example.com".into()), + organization: None, + subscription_type: None, + token_source: None, + api_key_source: None, + }, + }, + ); + + assert!(app.account_info.is_none()); + } + + #[test] + fn stale_mcp_snapshot_for_old_session_is_ignored() { + let mut app = make_test_app(); + app.session_id = Some(model::SessionId::new("current-session")); + app.mcp.servers.push(crate::agent::types::McpServerStatus { + name: "current".into(), + status: crate::agent::types::McpServerConnectionStatus::Connected, + server_info: None, + error: None, + config: None, + scope: None, + tools: Vec::new(), + }); + + handle_client_event( + &mut app, + ClientEvent::McpSnapshotReceived { + session_id: "old-session".into(), + servers: vec![crate::agent::types::McpServerStatus { + name: "stale".into(), + status: crate::agent::types::McpServerConnectionStatus::Connected, + server_info: None, + error: None, + config: None, + scope: None, + tools: Vec::new(), + }], + error: None, + }, + ); + + assert_eq!(app.mcp.servers.len(), 1); + assert_eq!(app.mcp.servers[0].name, "current"); + } + + #[test] + fn stale_usage_refresh_result_for_old_epoch_is_ignored() { + let mut app = make_test_app(); + app.session_scope_epoch = 5; + + handle_client_event( + &mut app, + ClientEvent::UsageSnapshotReceived { + epoch: 4, + snapshot: UsageSnapshot { + source: UsageSourceKind::Oauth, + fetched_at: std::time::SystemTime::now(), + five_hour: None, + seven_day: None, + seven_day_opus: None, + seven_day_sonnet: None, + extra_usage: None, + }, + }, + ); + + assert!(app.usage.snapshot.is_none()); + } + + #[test] + fn stale_plugin_inventory_result_for_old_cwd_is_ignored() { + let mut app = make_test_app(); + app.cwd_raw = "/current".into(); + + handle_client_event( + &mut app, + ClientEvent::PluginsInventoryUpdated { + cwd_raw: "/old".into(), + snapshot: crate::app::plugins::PluginsInventorySnapshot { + installed: vec![crate::app::plugins::InstalledPluginEntry { + id: "stale-plugin".into(), + version: None, + scope: "user".into(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: None, + capability: crate::app::plugins::PluginCapability::Skill, + }], + marketplace: Vec::new(), + marketplaces: Vec::new(), + }, + claude_path: std::path::PathBuf::from("claude"), + }, + ); + + assert!(app.plugins.installed.is_empty()); + } + + #[test] + fn slash_command_error_while_resuming_returns_ready_and_clears_marker() { + let mut app = make_test_app(); + app.status = AppStatus::CommandPending; + app.resuming_session_id = Some("resume-123".into()); + + handle_client_event(&mut app, ClientEvent::SlashCommandError("resume failed".into())); + + assert!(matches!(app.status, AppStatus::Ready)); + assert!(app.resuming_session_id.is_none()); + } + + #[test] + fn sessions_listed_completes_pending_session_rename() { + let mut app = make_test_app(); + app.config.pending_session_title_change = + Some(crate::app::config::PendingSessionTitleChangeState { + session_id: "session-1".to_owned(), + kind: crate::app::config::PendingSessionTitleChangeKind::Rename { + requested_title: Some("Renamed session".to_owned()), + }, + }); + + handle_client_event( + &mut app, + ClientEvent::SessionsListed { + sessions: vec![crate::agent::types::SessionListEntry { + session_id: "session-1".to_owned(), + summary: "Renamed session".to_owned(), + last_modified_ms: 1, + file_size_bytes: 2, + cwd: Some("/test".to_owned()), + git_branch: None, + custom_title: Some("Renamed session".to_owned()), + first_prompt: Some("prompt".to_owned()), + }], + }, + ); + + assert!(app.config.pending_session_title_change.is_none()); + assert_eq!( + app.config.status_message.as_deref(), + Some("Renamed session to Renamed session") + ); + assert!(app.config.last_error.is_none()); + assert_eq!(app.recent_sessions.len(), 1); + } + + #[test] + fn slash_command_error_for_pending_session_rename_stays_in_config_feedback() { + let mut app = make_test_app(); + app.config.pending_session_title_change = + Some(crate::app::config::PendingSessionTitleChangeState { + session_id: "session-1".to_owned(), + kind: crate::app::config::PendingSessionTitleChangeKind::Rename { + requested_title: Some("Renamed session".to_owned()), + }, + }); + + handle_client_event( + &mut app, + ClientEvent::SlashCommandError("failed to rename session: boom".into()), + ); + + assert!(app.config.pending_session_title_change.is_none()); + assert_eq!(app.config.last_error.as_deref(), Some("failed to rename session: boom")); + assert!(app.config.status_message.is_none()); + assert!(app.messages.is_empty()); + } + + #[test] + fn mcp_operation_error_stays_in_mcp_feedback_and_out_of_chat() { + let mut app = make_test_app(); + app.config.active_tab = crate::app::config::ConfigTab::Mcp; + app.config.status_message = + Some("Starting MCP auth for claude.ai Google Calendar...".into()); + app.mcp.in_flight = true; + + handle_client_event( + &mut app, + ClientEvent::McpOperationError { + error: crate::agent::types::McpOperationError { + server_name: Some("claude.ai Google Calendar".into()), + operation: "authenticate".into(), + message: "Server type \"claudeai-proxy\" does not support OAuth authentication" + .into(), + }, + }, + ); + + assert_eq!( + app.mcp.last_error.as_deref(), + Some( + "Failed to authenticate MCP server claude.ai Google Calendar: Server type \"claudeai-proxy\" does not support OAuth authentication" + ) + ); + assert_eq!(app.config.last_error, app.mcp.last_error); + assert!(app.config.status_message.is_none()); + assert!(!app.mcp.in_flight); + assert!(app.messages.is_empty()); + } + + #[test] + fn sessions_listed_completes_pending_session_title_generation() { + let mut app = make_test_app(); + app.config.pending_session_title_change = + Some(crate::app::config::PendingSessionTitleChangeState { + session_id: "session-1".to_owned(), + kind: crate::app::config::PendingSessionTitleChangeKind::Generate, + }); + + handle_client_event( + &mut app, + ClientEvent::SessionsListed { + sessions: vec![crate::agent::types::SessionListEntry { + session_id: "session-1".to_owned(), + summary: "Generated session".to_owned(), + last_modified_ms: 1, + file_size_bytes: 2, + cwd: Some("/test".to_owned()), + git_branch: None, + custom_title: Some("Generated session".to_owned()), + first_prompt: Some("prompt".to_owned()), + }], + }, + ); + + assert!(app.config.pending_session_title_change.is_none()); + assert_eq!(app.config.status_message.as_deref(), Some("Generated session title")); + assert!(app.config.last_error.is_none()); + } + + #[test] + fn current_mode_update_clears_pending_when_expected() { + let mut app = make_test_app(); + app.status = AppStatus::CommandPending; + app.pending_command_label = Some("Switching mode...".into()); + app.pending_command_ack = Some(PendingCommandAck::CurrentModeUpdate); + app.mode = Some(crate::app::ModeState { + current_mode_id: "code".to_owned(), + current_mode_name: "Code".to_owned(), + available_modes: vec![ + crate::app::ModeInfo { id: "code".to_owned(), name: "Code".to_owned() }, + crate::app::ModeInfo { id: "plan".to_owned(), name: "Plan".to_owned() }, + ], + }); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::CurrentModeUpdate( + model::CurrentModeUpdate::new("plan"), + )), + ); + + assert!(matches!(app.status, AppStatus::Ready)); + assert!(app.pending_command_label.is_none()); + assert!(app.pending_command_ack.is_none()); + let mode = app.mode.expect("mode should be present"); + assert_eq!(mode.current_mode_id, "plan"); + assert_eq!(mode.current_mode_name, "Plan"); + } + + #[test] + fn model_config_option_update_updates_state_and_clears_pending_when_expected() { + let mut app = make_test_app(); + app.status = AppStatus::CommandPending; + app.pending_command_label = Some("Switching model...".into()); + app.pending_command_ack = + Some(PendingCommandAck::ConfigOptionUpdate { option_id: "model".to_owned() }); + app.model_name = "old-model".to_owned(); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ConfigOptionUpdate( + model::ConfigOptionUpdate { + option_id: "model".to_owned(), + value: serde_json::Value::String("sonnet".to_owned()), + }, + )), + ); + + assert!(matches!(app.status, AppStatus::Ready)); + assert_eq!(app.model_name, "sonnet"); + assert_eq!( + app.config_options.get("model"), + Some(&serde_json::Value::String("sonnet".to_owned())) + ); + assert!(app.pending_command_label.is_none()); + assert!(app.pending_command_ack.is_none()); + } + + #[test] + fn non_matching_config_option_update_keeps_pending() { + let mut app = make_test_app(); + app.status = AppStatus::CommandPending; + app.pending_command_label = Some("Switching model...".into()); + app.pending_command_ack = + Some(PendingCommandAck::ConfigOptionUpdate { option_id: "model".to_owned() }); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::ConfigOptionUpdate( + model::ConfigOptionUpdate { + option_id: "max_thinking_tokens".to_owned(), + value: serde_json::json!(2048), + }, + )), + ); + + assert!(matches!(app.status, AppStatus::CommandPending)); + assert_eq!(app.config_options.get("max_thinking_tokens"), Some(&serde_json::json!(2048))); + assert_eq!(app.pending_command_label.as_deref(), Some("Switching model...")); + assert!(matches!( + app.pending_command_ack.as_ref(), + Some(PendingCommandAck::ConfigOptionUpdate { option_id }) if option_id == "model" + )); + } + + #[test] + fn resume_does_not_add_confirmation_system_message() { + let mut app = make_test_app(); + app.resuming_session_id = Some("requested-123".into()); + + handle_client_event( + &mut app, + ClientEvent::SessionReplaced { + session_id: model::SessionId::new("active-456"), + cwd: "/replacement".into(), + model_name: "new-model".into(), + available_models: Vec::new(), + mode: None, + history_updates: Vec::new(), + }, + ); + + assert_eq!(app.messages.len(), 1); + assert!(matches!(app.messages[0].role, MessageRole::Welcome)); + assert!(app.resuming_session_id.is_none()); + assert!(matches!(app.status, AppStatus::Ready)); + } + + #[test] + fn resume_history_renders_user_message_chunks() { + let mut app = make_test_app(); + let history_updates = vec![ + model::SessionUpdate::UserMessageChunk(model::ContentChunk::new( + model::ContentBlock::Text(model::TextContent::new("first user line")), + )), + model::SessionUpdate::AgentMessageChunk(model::ContentChunk::new( + model::ContentBlock::Text(model::TextContent::new("assistant reply")), + )), + ]; + + handle_client_event( + &mut app, + ClientEvent::SessionReplaced { + session_id: model::SessionId::new("active-456"), + cwd: "/replacement".into(), + model_name: "new-model".into(), + available_models: Vec::new(), + mode: None, + history_updates, + }, + ); + + assert_eq!(app.messages.len(), 3); + assert!(matches!(app.messages[0].role, MessageRole::Welcome)); + assert!(matches!(app.messages[1].role, MessageRole::User)); + assert!(matches!(app.messages[2].role, MessageRole::Assistant)); + + let Some(MessageBlock::Text(user_text)) = app.messages[1].blocks.first() else { + panic!("expected user text block"); + }; + assert_eq!(user_text.text, "first user line"); + } + + #[test] + fn resume_history_forces_open_tool_calls_to_failed() { + let mut app = make_test_app(); + let open_tool = model::ToolCall::new("resume-open", "Execute command") + .kind(model::ToolKind::Execute) + .status(model::ToolCallStatus::InProgress); + + handle_client_event( + &mut app, + ClientEvent::SessionReplaced { + session_id: model::SessionId::new("active-789"), + cwd: "/replacement".into(), + model_name: "new-model".into(), + available_models: Vec::new(), + mode: None, + history_updates: vec![model::SessionUpdate::ToolCall(open_tool)], + }, + ); + + let Some((mi, bi)) = app.lookup_tool_call("resume-open") else { + panic!("missing tool call index"); + }; + let Some(MessageBlock::ToolCall(tc)) = app.messages.get(mi).and_then(|m| m.blocks.get(bi)) + else { + panic!("expected tool call block"); + }; + assert_eq!(tc.status, model::ToolCallStatus::Failed); + } + + #[test] + fn resume_history_clears_active_turn_owner_after_replay() { + let mut app = make_test_app(); + + handle_client_event( + &mut app, + ClientEvent::SessionReplaced { + session_id: model::SessionId::new("active-790"), + cwd: "/replacement".into(), + model_name: "new-model".into(), + available_models: Vec::new(), + mode: None, + history_updates: vec![model::SessionUpdate::AgentMessageChunk( + model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new( + "assistant reply", + ))), + )], + }, + ); + + assert_eq!(app.active_turn_assistant_idx(), None); + } + + #[test] + fn resume_history_clears_tool_scope_tracking_after_replay() { + let mut app = make_test_app(); + let task_tool = model::ToolCall::new("resume-task", "Run subagent") + .kind(model::ToolKind::Think) + .status(model::ToolCallStatus::InProgress) + .meta(serde_json::json!({"claudeCode": {"toolName": "Task"}})); + + handle_client_event( + &mut app, + ClientEvent::SessionReplaced { + session_id: model::SessionId::new("active-791"), + cwd: "/replacement".into(), + model_name: "new-model".into(), + available_models: Vec::new(), + mode: None, + history_updates: vec![model::SessionUpdate::ToolCall(task_tool)], + }, + ); + + assert!(app.active_task_ids.is_empty()); + assert!(app.active_subagent_tool_ids.is_empty()); + assert_eq!(app.tool_call_scope("resume-task"), None); + } + + #[test] + fn turn_complete_without_cancel_does_not_render_interrupted_hint() { + let mut app = make_test_app(); + handle_client_event(&mut app, ClientEvent::TurnComplete); + assert!(app.messages.is_empty()); + } + + #[test] + fn turn_complete_keeps_history_and_adds_compaction_success_after_manual_boundary() { + let mut app = make_test_app(); + app.session_id = Some(model::SessionId::new("session-x")); + app.messages.push(user_msg("/compact")); + app.messages + .push(assistant_msg(vec![MessageBlock::Text(TextBlock::from_complete("compacted"))])); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::CompactionBoundary( + model::CompactionBoundary { + trigger: model::CompactionTrigger::Manual, + pre_tokens: 123_456, + }, + )), + ); + assert!(app.pending_compact_clear); + + handle_client_event(&mut app, ClientEvent::TurnComplete); + + assert!(!app.pending_compact_clear); + assert_eq!(app.messages.len(), 3); + let Some(ChatMessage { + role: MessageRole::System(Some(SystemSeverity::Info)), blocks, .. + }) = app.messages.last() + else { + panic!("expected compaction success system message"); + }; + let Some(MessageBlock::Text(block)) = blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Session successfully compacted."); + assert_eq!(app.session_id.as_ref().map(ToString::to_string).as_deref(), Some("session-x")); + } + + #[test] + fn first_agent_chunk_clears_unconfirmed_compacting_without_success_message() { + let mut app = make_test_app(); + app.is_compacting = true; + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::AgentMessageChunk( + model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new( + "regular answer", + ))), + )), + ); + + assert!(!app.is_compacting); + assert!(!app.pending_compact_clear); + assert!(app.messages.iter().all(|message| { + !matches!( + message, + ChatMessage { role: MessageRole::System(Some(SystemSeverity::Info)), .. } + ) + })); + } + + #[test] + fn session_status_idle_does_not_emit_compaction_success_without_boundary() { + let mut app = make_test_app(); + app.is_compacting = true; + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::SessionStatusUpdate( + model::SessionStatus::Idle, + )), + ); + + assert!(!app.is_compacting); + assert!(!app.pending_compact_clear); + assert!(app.messages.is_empty()); + } + + #[test] + fn turn_error_keeps_history_when_compact_pending() { + let mut app = make_test_app(); + app.pending_compact_clear = true; + app.messages.push(user_msg("/compact")); + + handle_client_event(&mut app, ClientEvent::TurnError("adapter failed".into())); + + assert!(!app.pending_compact_clear); + assert!(matches!(app.status, AppStatus::Error)); + assert_eq!(app.messages.len(), 3); + assert!(matches!(app.messages[0].role, MessageRole::User)); + let Some(ChatMessage { + role: MessageRole::System(Some(SystemSeverity::Info)), blocks, .. + }) = app.messages.get(1) + else { + panic!("expected compaction success system message"); + }; + let Some(MessageBlock::Text(block)) = blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Session successfully compacted."); + let Some(ChatMessage { role: MessageRole::System(_), blocks, .. }) = app.messages.last() + else { + panic!("expected system error message"); + }; + let Some(MessageBlock::Text(block)) = blocks.first() else { + panic!("expected text block"); + }; + assert!(block.text.contains("Turn failed: adapter failed")); + assert!(block.text.contains("Press Ctrl+Q to quit and try again")); + } + + #[test] + fn turn_cancel_keeps_manual_compaction_success_pending_until_exit() { + let mut app = make_test_app(); + app.pending_compact_clear = true; + app.is_compacting = true; + + handle_client_event(&mut app, ClientEvent::TurnCancelled); + + assert!(app.pending_compact_clear); + assert!(app.is_compacting); + } + + #[test] + fn turn_error_after_cancel_keeps_compaction_success_before_interrupted_hint() { + let mut app = make_test_app(); + app.messages.push(user_msg("/compact")); + app.pending_compact_clear = true; + app.is_compacting = true; + + handle_client_event(&mut app, ClientEvent::TurnCancelled); + handle_client_event(&mut app, ClientEvent::TurnError("cancelled".into())); + + assert_eq!(app.messages.len(), 3); + assert!(matches!(app.messages[1].role, MessageRole::System(Some(SystemSeverity::Info)))); + let Some(MessageBlock::Text(block)) = app.messages[1].blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Session successfully compacted."); + let Some(MessageBlock::Text(block)) = app.messages[2].blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Conversation interrupted. Tell the model how to proceed."); + } + + #[test] + fn turn_error_plan_limit_shows_next_steps_guidance() { + let mut app = make_test_app(); + + handle_client_event( + &mut app, + ClientEvent::TurnError("HTTP 429 Too Many Requests: max turns exceeded".into()), + ); + + assert!(matches!(app.status, AppStatus::Error)); + let Some(ChatMessage { role: MessageRole::System(_), blocks, .. }) = app.messages.last() + else { + panic!("expected system error message"); + }; + let Some(MessageBlock::Text(block)) = blocks.first() else { + panic!("expected text block"); + }; + assert!(block.text.contains("Turn blocked by account or plan limits")); + assert!(block.text.contains("Next steps:")); + assert!(block.text.contains("Check quota/billing")); + } + + #[test] + fn classified_turn_error_plan_limit_uses_guidance_without_text_matching() { + let mut app = make_test_app(); + + handle_client_event( + &mut app, + ClientEvent::TurnErrorClassified { + message: "turn failed".into(), + class: TurnErrorClass::PlanLimit, + }, + ); + + assert!(matches!(app.status, AppStatus::Error)); + let Some(ChatMessage { role: MessageRole::System(_), blocks, .. }) = app.messages.last() + else { + panic!("expected system error message"); + }; + let Some(MessageBlock::Text(block)) = blocks.first() else { + panic!("expected text block"); + }; + assert!(block.text.contains("Turn blocked by account or plan limits")); + assert!(block.text.contains("Next steps:")); + } + + #[test] + fn classified_turn_error_auth_required_sets_exit_error_and_quits() { + let mut app = make_test_app(); + + handle_client_event( + &mut app, + ClientEvent::TurnErrorClassified { + message: "auth required".into(), + class: TurnErrorClass::AuthRequired, + }, + ); + + assert!(matches!(app.status, AppStatus::Error)); + assert!(app.should_quit); + assert_eq!(app.exit_error, Some(crate::error::AppError::AuthRequired)); + } + + #[test] + fn turn_error_clears_tool_scope_tracking() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "task-1", + model::ToolCallStatus::InProgress, + )))])); + app.register_tool_call_scope("task-1".into(), ToolCallScope::Task); + app.insert_active_task("task-1".into()); + + handle_client_event(&mut app, ClientEvent::TurnError("boom".into())); + + assert!(app.active_task_ids.is_empty()); + assert!(app.active_subagent_tool_ids.is_empty()); + assert_eq!(app.tool_call_scope("task-1"), None); + } + + #[test] + fn auth_required_clears_active_turn_runtime_tracking() { + let mut app = make_test_app(); + app.status = AppStatus::Running; + app.session_id = Some(model::SessionId::new("session-auth")); + app.model_name = "claude-old".into(); + app.mode = Some(crate::app::ModeState { + current_mode_id: "plan".into(), + current_mode_name: "Plan".into(), + available_modes: vec![crate::app::ModeInfo { id: "plan".into(), name: "Plan".into() }], + }); + app.fast_mode_state = model::FastModeState::On; + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "task-1", + model::ToolCallStatus::InProgress, + )))])); + app.bind_active_turn_assistant(0); + app.register_tool_call_scope("task-1".into(), ToolCallScope::Task); + app.insert_active_task("task-1".into()); + app.pending_interaction_ids.push("task-1".into()); + app.claim_focus_target(FocusTarget::Permission); + + handle_client_event( + &mut app, + ClientEvent::AuthRequired { + method_name: "oauth".into(), + method_description: "Open browser".into(), + }, + ); + + assert_eq!(app.active_turn_assistant_idx(), None); + assert!(app.active_task_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); + assert_ne!(app.focus_owner(), FocusOwner::Permission); + let Some(MessageBlock::ToolCall(tc)) = app.messages[0].blocks.first() else { + panic!("expected tool call block"); + }; + assert_eq!(tc.status, model::ToolCallStatus::Failed); + assert!(app.session_id.is_none()); + assert_eq!(app.model_name, "Connecting..."); + assert!(app.mode.is_none()); + assert_eq!(app.fast_mode_state, model::FastModeState::Off); + } + + #[test] + fn logout_completed_clears_session_runtime_identity_caches() { + let mut app = make_test_app(); + app.session_id = Some(model::SessionId::new("session-x")); + app.model_name = "claude-old".into(); + app.mode = Some(crate::app::ModeState { + current_mode_id: "plan".into(), + current_mode_name: "Plan".into(), + available_modes: vec![crate::app::ModeInfo { id: "plan".into(), name: "Plan".into() }], + }); + app.fast_mode_state = model::FastModeState::On; + + handle_client_event(&mut app, ClientEvent::LogoutCompleted); + + assert!(app.session_id.is_none()); + assert_eq!(app.model_name, "Connecting..."); + assert!(app.mode.is_none()); + assert_eq!(app.fast_mode_state, model::FastModeState::Off); + } + + #[test] + fn fatal_event_sets_exit_error_and_quits() { + let mut app = make_test_app(); + + handle_client_event( + &mut app, + ClientEvent::FatalError(crate::error::AppError::ConnectionFailed), + ); + + assert!(matches!(app.status, AppStatus::Error)); + assert!(app.should_quit); + assert_eq!(app.exit_error, Some(crate::error::AppError::ConnectionFailed)); + } + + #[test] + fn connection_failed_clears_active_turn_runtime_tracking() { + let mut app = make_test_app(); + app.status = AppStatus::Running; + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "task-1", + model::ToolCallStatus::InProgress, + )))])); + app.bind_active_turn_assistant(0); + app.register_tool_call_scope("task-1".into(), ToolCallScope::Task); + app.insert_active_task("task-1".into()); + + handle_client_event(&mut app, ClientEvent::ConnectionFailed("bridge down".into())); + + assert_eq!(app.active_turn_assistant_idx(), None); + assert!(app.active_task_ids.is_empty()); + let Some(MessageBlock::ToolCall(tc)) = app.messages[0].blocks.first() else { + panic!("expected tool call block"); + }; + assert_eq!(tc.status, model::ToolCallStatus::Failed); + } + + #[test] + fn fatal_event_clears_active_turn_runtime_tracking() { + let mut app = make_test_app(); + app.status = AppStatus::Running; + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( + "task-1", + model::ToolCallStatus::InProgress, + )))])); + app.bind_active_turn_assistant(0); + app.register_tool_call_scope("task-1".into(), ToolCallScope::Task); + app.insert_active_task("task-1".into()); + + handle_client_event( + &mut app, + ClientEvent::FatalError(crate::error::AppError::ConnectionFailed), + ); + + assert_eq!(app.active_turn_assistant_idx(), None); + assert!(app.active_task_ids.is_empty()); + let Some(MessageBlock::ToolCall(tc)) = app.messages[0].blocks.first() else { + panic!("expected tool call block"); + }; + assert_eq!(tc.status, model::ToolCallStatus::Failed); + } + + #[test] + fn compaction_boundary_enables_compacting_and_records_boundary() { + let mut app = make_test_app(); + assert!(!app.is_compacting); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::CompactionBoundary( + model::CompactionBoundary { + trigger: model::CompactionTrigger::Manual, + pre_tokens: 123_456, + }, + )), + ); + + assert!(app.is_compacting); + assert!(app.pending_compact_clear); + assert_eq!( + app.session_usage.last_compaction_trigger, + Some(model::CompactionTrigger::Manual) + ); + assert_eq!(app.session_usage.last_compaction_pre_tokens, Some(123_456)); + } + + #[test] + fn auto_compaction_boundary_sets_compacting_without_manual_success_pending() { + let mut app = make_test_app(); + assert!(!app.is_compacting); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::CompactionBoundary( + model::CompactionBoundary { + trigger: model::CompactionTrigger::Auto, + pre_tokens: 234_567, + }, + )), + ); + + assert!(app.is_compacting); + assert!(!app.pending_compact_clear); + assert_eq!(app.session_usage.last_compaction_trigger, Some(model::CompactionTrigger::Auto)); + assert_eq!(app.session_usage.last_compaction_pre_tokens, Some(234_567)); + } + + #[test] + fn fast_mode_update_sets_state() { + let mut app = make_test_app(); + assert_eq!(app.fast_mode_state, model::FastModeState::Off); + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::FastModeUpdate( + model::FastModeState::Cooldown, + )), + ); + + assert_eq!(app.fast_mode_state, model::FastModeState::Cooldown); + } + + #[test] + fn rate_limit_warning_transitions_once_and_rejected_emits_each_event() { + let mut app = make_test_app(); + + let warning_update = model::RateLimitUpdate { + status: model::RateLimitStatus::AllowedWarning, + resets_at: Some(123.0), + utilization: Some(0.92), + rate_limit_type: Some("five_hour".to_owned()), + overage_status: None, + overage_resets_at: None, + overage_disabled_reason: None, + is_using_overage: None, + surpassed_threshold: None, + }; + + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::RateLimitUpdate( + warning_update.clone(), + )), + ); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::RateLimitUpdate( + warning_update.clone(), + )), + ); + + assert_eq!(app.messages.len(), 1); + assert!(matches!(app.messages[0].role, MessageRole::System(Some(SystemSeverity::Warning)))); + + let rejected_update = + model::RateLimitUpdate { status: model::RateLimitStatus::Rejected, ..warning_update }; + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::RateLimitUpdate( + rejected_update.clone(), + )), + ); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate(model::SessionUpdate::RateLimitUpdate(rejected_update)), + ); + + assert_eq!(app.messages.len(), 3); + assert!(matches!(app.messages[1].role, MessageRole::System(_))); + assert!(matches!(app.messages[2].role, MessageRole::System(_))); + } + + #[test] + fn plan_limit_turn_error_includes_rate_limit_context_and_warning_severity() { + let mut app = make_test_app(); + app.last_rate_limit_update = Some(model::RateLimitUpdate { + status: model::RateLimitStatus::AllowedWarning, + resets_at: Some(1_741_280_000.0), + utilization: Some(0.95), + rate_limit_type: Some("five_hour".to_owned()), + overage_status: None, + overage_resets_at: None, + overage_disabled_reason: None, + is_using_overage: None, + surpassed_threshold: None, + }); + + handle_client_event( + &mut app, + ClientEvent::TurnErrorClassified { + message: "HTTP 429 Too Many Requests".to_owned(), + class: TurnErrorClass::PlanLimit, + }, + ); + + let Some(last) = app.messages.last() else { + panic!("expected combined system message"); + }; + assert!(matches!(last.role, MessageRole::System(Some(SystemSeverity::Warning)))); + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert!(block.text.contains("Approaching rate limit")); + assert!(block.text.contains("Turn blocked by account or plan limits")); + } + + #[test] + fn turn_error_after_cancel_shows_interrupted_hint_instead_of_error_block() { + let mut app = make_test_app(); + app.messages.push(user_msg("build app")); + + handle_client_event(&mut app, ClientEvent::TurnCancelled); + assert!(app.cancelled_turn_pending_hint); + + handle_client_event( + &mut app, + ClientEvent::TurnError("Error: Request was aborted.\n at stack line".into()), + ); + + assert!(!app.cancelled_turn_pending_hint); + assert!(matches!(app.status, AppStatus::Ready)); + + let Some(last) = app.messages.last() else { + panic!("expected interruption hint message"); + }; + assert!(matches!(last.role, MessageRole::System(Some(SystemSeverity::Info)))); + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Conversation interrupted. Tell the model how to proceed."); + } + + #[test] + fn turn_error_after_auto_cancel_marks_tail_assistant_layout_dirty() { + let mut app = make_test_app(); + app.status = AppStatus::Running; + app.messages.push(user_msg("build app")); + app.messages.push(assistant_msg(vec![MessageBlock::Text(TextBlock::from_complete( + "partial output", + ))])); + app.pending_cancel_origin = Some(CancelOrigin::AutoQueue); + + handle_client_event( + &mut app, + ClientEvent::TurnError("Error: Request was aborted.\n at stack line".into()), + ); + + assert!(matches!(app.status, AppStatus::Ready)); + assert!(!app.viewport.message_height_is_current(1)); + assert_eq!(app.messages.len(), 2); + let Some(last) = app.messages.last() else { + panic!("expected assistant message"); + }; + assert!(matches!(last.role, MessageRole::Assistant)); + } + + #[test] + fn turn_cancel_marks_active_tools_failed() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![ + MessageBlock::ToolCall(Box::new(tool_call("tc1", model::ToolCallStatus::InProgress))), + MessageBlock::ToolCall(Box::new(tool_call("tc2", model::ToolCallStatus::Pending))), + MessageBlock::ToolCall(Box::new(tool_call("tc3", model::ToolCallStatus::Completed))), + ])); + + handle_client_event(&mut app, ClientEvent::TurnCancelled); + + let Some(last) = app.messages.last() else { + panic!("missing assistant message"); + }; + let statuses: Vec = last + .blocks + .iter() + .filter_map(|b| match b { + MessageBlock::ToolCall(tc) => Some(tc.status), + _ => None, + }) + .collect(); + assert_eq!( + statuses, + vec![ + model::ToolCallStatus::Failed, + model::ToolCallStatus::Failed, + model::ToolCallStatus::Completed + ] + ); + } + + #[test] + fn turn_complete_marks_lingering_tools_completed() { + let mut app = make_test_app(); + app.messages.push(assistant_msg(vec![ + MessageBlock::ToolCall(Box::new(tool_call("tc1", model::ToolCallStatus::InProgress))), + MessageBlock::ToolCall(Box::new(tool_call("tc2", model::ToolCallStatus::Pending))), + ])); + + handle_client_event(&mut app, ClientEvent::TurnComplete); + + let Some(last) = app.messages.last() else { + panic!("missing assistant message"); + }; + let statuses: Vec = last + .blocks + .iter() + .filter_map(|b| match b { + MessageBlock::ToolCall(tc) => Some(tc.status), + _ => None, + }) + .collect(); + assert_eq!( + statuses, + vec![model::ToolCallStatus::Completed, model::ToolCallStatus::Completed] + ); + } + + #[test] + fn ctrl_v_not_inserted_as_text() { + let mut app = make_test_app(); + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), ""); + } + + #[test] + fn ctrl_v_not_inserted_when_mention_key_handler_is_active() { + let mut app = make_test_app(); + handle_mention_key(&mut app, KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), ""); + } + + #[test] + fn pending_paste_payload_blocks_overlapping_key_text_insertion() { + let mut app = make_test_app(); + app.pending_paste_text = "clipboard".to_owned(); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(app.input.text(), ""); + } + + #[test] + fn altgr_at_inserts_char_and_activates_mention() { + let mut app = make_test_app(); + handle_normal_key( + &mut app, + KeyEvent::new(KeyCode::Char('@'), KeyModifiers::CONTROL | KeyModifiers::ALT), + ); + + assert_eq!(app.input.text(), "@"); + assert!(app.mention.is_some()); + } + + #[test] + fn ctrl_backspace_and_delete_use_word_operations() { + let mut app = make_test_app(); + app.input.set_text("hello world"); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), "hello "); + + app.input.move_home(); + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), " "); + } + + #[test] + fn ctrl_z_and_y_undo_and_redo_textarea_history() { + let mut app = make_test_app(); + app.input.set_text("hello world"); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), "hello "); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), "hello world"); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), "hello "); + } + + #[test] + fn ctrl_left_right_move_by_word() { + let mut app = make_test_app(); + app.input.set_text("hello world"); + app.input.move_home(); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL)); + assert!(app.input.cursor_col() > 0); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)); + assert_eq!(app.input.cursor_col(), 0); + } + + #[test] + fn help_overlay_left_right_switches_help_view_tab() { + let mut app = make_test_app(); + app.input.set_text("?"); + app.help_open = true; + app.help_view = HelpView::Keys; + + dispatch_key_by_focus(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(app.help_view, HelpView::SlashCommands); + + dispatch_key_by_focus(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(app.help_view, HelpView::Subagents); + + dispatch_key_by_focus(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(app.help_view, HelpView::SlashCommands); + + dispatch_key_by_focus(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(app.help_view, HelpView::Keys); + } + + #[test] + fn tab_toggles_todo_focus_target_for_open_todos() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.focus_owner(), FocusOwner::Input); + } + + #[test] + fn up_down_in_todo_focus_changes_todo_selection() { + let mut app = make_test_app(); + app.todos = vec![ + TodoItem { + content: "Task 1".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }, + TodoItem { + content: "Task 2".into(), + status: TodoStatus::InProgress, + active_form: String::new(), + }, + TodoItem { + content: "Task 3".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }, + ]; + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + app.todo_selected = 1; + + let before_cursor_row = app.input.cursor_row(); + let before_cursor_col = app.input.cursor_col(); + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(app.todo_selected, 2); + assert_eq!(app.input.cursor_row(), before_cursor_row); + assert_eq!(app.input.cursor_col(), before_cursor_col); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(app.todo_selected, 1); + } + + #[test] + fn permission_owner_overrides_todo_focus_for_up_down() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + app.todo_selected = 0; + let _rx_a = attach_pending_permission( + &mut app, + "perm-a", + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + let _rx_b = attach_pending_permission( + &mut app, + "perm-b", + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + false, + ); + app.claim_focus_target(FocusTarget::Permission); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + ); + + assert_eq!(app.pending_interaction_ids, vec!["perm-b", "perm-a"]); + assert_eq!(app.todo_selected, 0); + } + + #[test] + fn permission_focus_allows_typing_for_non_permission_keys() { + let mut app = make_test_app(); + app.pending_interaction_ids.push("perm-1".into()); + app.claim_focus_target(FocusTarget::Permission); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)), + ); + + assert_eq!(app.input.text(), "h"); + } + + #[test] + fn permission_focus_allows_ctrl_t_toggle_todos() { + let mut app = make_test_app(); + app.pending_interaction_ids.push("perm-1".into()); + app.claim_focus_target(FocusTarget::Permission); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + + assert!(!app.show_todo_panel); + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + ); + assert!(app.show_todo_panel); + } + + #[test] + fn permission_focus_ctrl_t_moves_focus_to_todo_list() { + let mut app = make_test_app(); + let _response_rx = attach_pending_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + ); + + assert!(app.show_todo_panel); + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + } + + #[test] + fn stale_inline_interaction_queue_head_is_pruned_before_enter_response() { + let mut app = make_test_app(); + let mut response_rx = attach_pending_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + false, + ); + app.pending_interaction_ids.insert(0, "stale-id".into()); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ); + + let response = response_rx.try_recv().expect("permission response"); + assert!(matches!(response.outcome, model::RequestPermissionOutcome::Selected(_))); + assert!(app.pending_interaction_ids.is_empty()); + } + + #[test] + fn permission_focus_tab_moves_focus_to_todo_list() { + let mut app = make_test_app(); + let _response_rx = attach_pending_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), + ); + + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + } + + #[test] + fn ctrl_u_hides_update_hint_globally() { + let mut app = make_test_app(); + app.update_check_hint = Some("Update available: v9.9.9 (current v0.2.0)".into()); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)), + ); + + assert!(app.update_check_hint.is_none()); + } + + fn attach_pending_permission( + app: &mut App, + tool_id: &str, + options: Vec, + focused: bool, + ) -> oneshot::Receiver { + let (response_tx, response_rx) = oneshot::channel(); + let mut tc = tool_call(tool_id, model::ToolCallStatus::InProgress); + tc.pending_permission = + Some(InlinePermission { options, response_tx, selected_index: 0, focused }); + app.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tc))])); + let msg_idx = app.messages.len().saturating_sub(1); + app.index_tool_call(tool_id.into(), msg_idx, 0); + app.pending_interaction_ids.push(tool_id.into()); + app.claim_focus_target(FocusTarget::Permission); + response_rx + } + + fn push_todo_and_focus(app: &mut App) { + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + } + + #[test] + fn permission_ctrl_y_works_even_when_todo_focus_owns_navigation() { + let mut app = make_test_app(); + let mut response_rx = attach_pending_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + + // Override focus owner to todo to prove the quick shortcut is global. + push_todo_and_focus(&mut app); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)), + ); + + let resp = response_rx.try_recv().expect("ctrl+y should resolve pending permission"); + let model::RequestPermissionOutcome::Selected(selected) = resp.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(selected.option_id.clone(), "allow"); + assert!(app.pending_interaction_ids.is_empty()); + } + + #[test] + fn permission_ctrl_a_works_even_when_todo_focus_owns_navigation() { + let mut app = make_test_app(); + let mut response_rx = attach_pending_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow-once", + "Allow once", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "allow-always", + "Allow always", + model::PermissionOptionKind::AllowAlways, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + push_todo_and_focus(&mut app); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)), + ); + + let resp = response_rx.try_recv().expect("ctrl+a should resolve pending permission"); + let model::RequestPermissionOutcome::Selected(selected) = resp.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(selected.option_id.clone(), "allow-always"); + assert!(app.pending_interaction_ids.is_empty()); + } + + #[test] + fn permission_ctrl_n_works_even_when_mention_focus_owns_navigation() { + let mut app = make_test_app(); + let mut response_rx = attach_pending_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + + app.slash = Some(SlashState { + trigger_row: 0, + trigger_col: 0, + query: String::new(), + context: SlashContext::CommandName, + candidates: vec![SlashCandidate { + insert_value: "/config".into(), + primary: "/config".into(), + secondary: Some("Open settings".into()), + }], + dialog: crate::app::dialog::DialogState::default(), + }); + app.claim_focus_target(FocusTarget::Mention); + assert_eq!(app.focus_owner(), FocusOwner::Mention); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)), + ); + + let resp = response_rx.try_recv().expect("ctrl+n should resolve pending permission"); + let model::RequestPermissionOutcome::Selected(selected) = resp.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(selected.option_id.clone(), "deny"); + assert!(app.pending_interaction_ids.is_empty()); + } + + #[test] + fn connecting_state_ctrl_c_with_non_empty_selection_does_not_quit() { + let mut app = make_test_app(); + let _clipboard = + crate::app::keys::override_test_clipboard(crate::app::keys::TestClipboardMode::Succeed); + app.status = AppStatus::Connecting; + app.rendered_input_lines = vec!["copy".to_owned()]; + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Input, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 4 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(!app.should_quit); + assert!(app.selection.is_none()); + } + + #[test] + fn second_esc_after_permission_rejection_requests_turn_cancel() { + let (mut app, mut rx) = app_with_bridge_connection(); + app.status = AppStatus::Running; + app.session_id = Some(model::SessionId::new("session-1")); + let mut response_rx = attach_pending_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ); + + let response = response_rx.try_recv().expect("first Esc should answer permission"); + let model::RequestPermissionOutcome::Selected(selected) = response.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(selected.option_id.clone(), "deny"); + assert!(app.pending_interaction_ids.is_empty()); + assert_eq!(app.pending_cancel_origin, None); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ); + + assert_eq!(app.pending_cancel_origin, Some(CancelOrigin::Manual)); + let envelope = rx.try_recv().expect("second Esc should send turn cancel"); + assert!(matches!( + envelope.command, + crate::agent::wire::BridgeCommand::CancelTurn { session_id } + if session_id == "session-1" + )); + } + + #[test] + fn connecting_state_allows_navigation_and_help_shortcuts() { + let mut app = make_test_app(); + app.status = AppStatus::Connecting; + app.help_view = HelpView::Keys; + app.viewport.scroll_target = 2; + + // Chat navigation remains available during startup. + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))); + assert_eq!(app.viewport.scroll_target, 1); + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + ); + assert_eq!(app.viewport.scroll_target, 2); + + // Help toggle via "?" remains available. + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)), + ); + assert!(app.is_help_active()); + + // Help tab navigation still works. + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + ); + assert_eq!(app.help_view, HelpView::SlashCommands); + } + + #[test] + fn connecting_state_blocks_input_shortcuts_and_tab() { + let mut app = make_test_app(); + app.status = AppStatus::Connecting; + app.input.set_text("seed"); + app.pending_submit = None; + app.help_view = HelpView::Keys; + + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ] { + handle_terminal_event(&mut app, Event::Key(key)); + } + + assert_eq!(app.input.text(), "seed"); + assert!(app.pending_submit.is_none()); + assert_eq!(app.help_view, HelpView::Keys); + } + + #[test] + fn ctrl_c_with_non_empty_selection_does_not_quit_and_clears_selection() { + let mut app = make_test_app(); + let _clipboard = + crate::app::keys::override_test_clipboard(crate::app::keys::TestClipboardMode::Succeed); + app.rendered_input_lines = vec!["copy".to_owned()]; + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Input, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 4 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(!app.should_quit); + assert!(app.selection.is_none()); + } + + #[test] + fn ctrl_c_without_selection_quits() { + let mut app = make_test_app(); + app.selection = None; + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(app.should_quit); + } + + #[test] + fn ctrl_c_second_press_after_copy_quits() { + let mut app = make_test_app(); + let _clipboard = + crate::app::keys::override_test_clipboard(crate::app::keys::TestClipboardMode::Succeed); + app.rendered_input_lines = vec!["copy".to_owned()]; + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Input, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 4 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + assert!(!app.should_quit); + assert!(app.selection.is_none()); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + assert!(app.should_quit); + } + + #[test] + fn ctrl_c_with_clipboard_failure_preserves_selection_without_quitting() { + let mut app = make_test_app(); + let _clipboard = + crate::app::keys::override_test_clipboard(crate::app::keys::TestClipboardMode::Fail); + app.rendered_input_lines = vec!["copy".to_owned()]; + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Input, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 4 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(!app.should_quit); + assert!(app.selection.is_some()); + } + + #[test] + fn ctrl_c_with_zero_length_selection_quits() { + let mut app = make_test_app(); + app.rendered_input_lines = vec!["copy".to_owned()]; + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Input, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 0 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(app.should_quit); + } + + #[test] + fn ctrl_c_with_whitespace_selection_copies_and_clears_selection() { + let mut app = make_test_app(); + let _clipboard = + crate::app::keys::override_test_clipboard(crate::app::keys::TestClipboardMode::Succeed); + app.rendered_input_lines = vec![" ".to_owned()]; + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Input, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 1 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(!app.should_quit); + assert!(app.selection.is_none()); + } + + #[test] + fn ctrl_q_quits_even_with_selection() { + let mut app = make_test_app(); + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Input, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 0 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)), + ); + + assert!(app.should_quit); + } + + #[test] + fn connecting_state_ctrl_q_quits() { + let mut app = make_test_app(); + app.status = AppStatus::Connecting; + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)), + ); + + assert!(app.should_quit); + } + + #[test] + fn error_state_blocks_input_shortcuts() { + let mut app = make_test_app(); + app.status = AppStatus::Error; + app.input.set_text("seed"); + app.pending_submit = None; + + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ] { + handle_terminal_event(&mut app, Event::Key(key)); + } + + assert_eq!(app.input.text(), "seed"); + assert!(app.pending_submit.is_none()); + } + + #[test] + fn error_state_ctrl_q_quits() { + let mut app = make_test_app(); + app.status = AppStatus::Error; + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)), + ); + + assert!(app.should_quit); + } + + #[test] + fn error_state_ctrl_c_quits() { + let mut app = make_test_app(); + app.status = AppStatus::Error; + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(app.should_quit); + } + + #[test] + fn error_state_blocks_paste_events() { + let mut app = make_test_app(); + app.status = AppStatus::Error; + + handle_terminal_event(&mut app, Event::Paste("blocked".into())); + + assert!(app.pending_paste_text.is_empty()); + assert!(app.input.is_empty()); + } + + #[test] + fn mouse_scroll_clears_selection_before_scrolling() { + let mut app = make_test_app(); + app.viewport.scroll_target = 2; + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Chat, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 1 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }), + ); + + assert!(app.selection.is_none()); + assert_eq!(app.viewport.scroll_target, 5); + } + + #[test] + fn mouse_down_on_scrollbar_rail_starts_drag_and_scrolls() { + let mut app = make_test_app(); + app.rendered_chat_area = Rect::new(0, 0, 20, 10); + app.viewport.height_prefix_sums = vec![30]; + app.viewport.scrollbar_thumb_top = 0.0; + app.viewport.scrollbar_thumb_size = 3.0; + app.selection = Some(crate::app::SelectionState { + kind: crate::app::SelectionKind::Chat, + start: crate::app::SelectionPoint { row: 0, col: 0 }, + end: crate::app::SelectionPoint { row: 0, col: 1 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: 19, + row: 9, + modifiers: KeyModifiers::NONE, + }), + ); + + assert!(app.scrollbar_drag.is_some()); + assert!(app.selection.is_none()); + assert!(!app.viewport.auto_scroll); + assert!(app.viewport.scroll_target > 0); + } + + #[test] + fn dragging_scrollbar_thumb_can_reach_bottom_and_top() { + let mut app = make_test_app(); + app.rendered_chat_area = Rect::new(0, 0, 20, 10); + app.viewport.height_prefix_sums = vec![30]; + app.viewport.scrollbar_thumb_top = 0.0; + app.viewport.scrollbar_thumb_size = 3.0; + + handle_terminal_event( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: 19, + row: 0, + modifiers: KeyModifiers::NONE, + }), + ); + handle_terminal_event( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(crossterm::event::MouseButton::Left), + column: 19, + row: 9, + modifiers: KeyModifiers::NONE, + }), + ); + assert_eq!(app.viewport.scroll_target, 20); + + handle_terminal_event( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(crossterm::event::MouseButton::Left), + column: 19, + row: 0, + modifiers: KeyModifiers::NONE, + }), + ); + assert_eq!(app.viewport.scroll_target, 0); + + handle_terminal_event( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(crossterm::event::MouseButton::Left), + column: 19, + row: 0, + modifiers: KeyModifiers::NONE, + }), + ); + assert!(app.scrollbar_drag.is_none()); + } + + #[test] + fn mention_owner_overrides_todo_focus_then_releases_back() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + app.slash = Some(SlashState { + trigger_row: 0, + trigger_col: 0, + query: String::new(), + context: SlashContext::CommandName, + candidates: vec![SlashCandidate { + insert_value: "/config".into(), + primary: "/config".into(), + secondary: Some("Open settings".into()), + }], + dialog: crate::app::dialog::DialogState::default(), + }); + app.claim_focus_target(FocusTarget::Mention); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ); + + assert!(app.mention.is_none()); + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + } + + #[test] + fn up_down_without_focus_scrolls_chat() { + let mut app = make_test_app(); + app.viewport.scroll_target = 5; + app.viewport.auto_scroll = true; + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(app.viewport.scroll_target, 4); + assert!(!app.viewport.auto_scroll); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(app.viewport.scroll_target, 5); + } + + #[test] + fn up_down_moves_input_cursor_when_multiline() { + let mut app = make_test_app(); + app.input.set_text("line1\nline2\nline3"); + let _ = app.input.set_cursor(1, 3); + app.viewport.scroll_target = 7; + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(app.input.cursor_row(), 0); + assert_eq!(app.viewport.scroll_target, 7); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(app.input.cursor_row(), 1); + assert_eq!(app.viewport.scroll_target, 7); + } + + #[test] + fn down_at_input_bottom_falls_back_to_chat_scroll() { + let mut app = make_test_app(); + app.input.set_text("line1\nline2"); + let _ = app.input.set_cursor(1, 0); + app.viewport.scroll_target = 2; + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + + assert_eq!(app.input.cursor_row(), 1); + assert_eq!(app.viewport.scroll_target, 3); + } + + #[test] + fn settings_view_routes_space_to_settings_handler_not_chat_input() { + let mut app = make_test_app(); + let dir = tempfile::tempdir().expect("tempdir"); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + crate::app::config::open(&mut app).expect("open settings"); + app.active_view = ActiveView::Config; + app.config.selected_setting_index = crate::app::config::setting_specs() + .iter() + .position(|spec| spec.id == crate::app::config::SettingId::FastMode) + .expect("fast mode setting row"); + app.input.set_text("seed"); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)), + ); + + assert_eq!(app.input.text(), "seed"); + assert!(app.pending_submit.is_none()); + assert!(app.config.fast_mode_effective()); + assert!(app.config.last_error.is_none()); + } + + #[test] + fn settings_view_routes_enter_to_close_not_chat_submit() { + let mut app = make_test_app(); + let dir = tempfile::tempdir().expect("tempdir"); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + crate::app::config::open(&mut app).expect("open settings"); + app.active_view = ActiveView::Config; + app.input.set_text("seed"); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ); + + assert_eq!(app.active_view, ActiveView::Chat); + assert_eq!(app.input.text(), "seed"); + assert!(app.pending_submit.is_none()); + } + + #[test] + fn settings_view_ignores_paste_events() { + let mut app = make_test_app(); + app.active_view = ActiveView::Config; + + handle_terminal_event(&mut app, Event::Paste("blocked".into())); + + assert!(app.pending_paste_text.is_empty()); + assert!(app.input.is_empty()); + } + + #[test] + fn settings_view_ignores_mouse_events() { + let mut app = make_test_app(); + app.active_view = ActiveView::Config; + app.viewport.scroll_target = 4; + app.selection = Some(SelectionState { + kind: SelectionKind::Chat, + start: SelectionPoint { row: 0, col: 0 }, + end: SelectionPoint { row: 0, col: 1 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }), + ); + + assert_eq!(app.viewport.scroll_target, 4); + assert!(app.selection.is_some()); + } + + #[test] + fn trusted_view_accept_key_does_not_edit_chat_input() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude.json"); + std::fs::write(&path, "{\n \"projects\": {}\n}\n").expect("write"); + + let mut app = make_test_app(); + app.active_view = ActiveView::Trusted; + app.input.set_text("seed"); + app.cwd_raw = dir.path().join("project").to_string_lossy().to_string(); + app.config.preferences_path = Some(path); + app.trust.status = crate::app::trust::TrustStatus::Untrusted; + app.trust.project_key = + crate::app::trust::store::normalize_project_key(std::path::Path::new(&app.cwd_raw)); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)), + ); + + assert_eq!(app.active_view, ActiveView::Chat); + assert_eq!(app.input.text(), "seed"); + assert!(app.pending_paste_text.is_empty()); + assert!(app.startup_connection_requested); + } + + #[test] + fn trusted_view_ignores_paste_events() { + let mut app = make_test_app(); + app.active_view = ActiveView::Trusted; + + handle_terminal_event(&mut app, Event::Paste("blocked".into())); + + assert!(app.pending_paste_text.is_empty()); + assert!(app.input.is_empty()); + } + + #[test] + fn buffered_paste_char_does_not_force_redraw() { + let mut app = make_test_app(); + let now = Instant::now(); + + assert_eq!( + app.paste_burst.on_char('a', now), + super::super::paste_burst::CharAction::Passthrough('a') + ); + assert_eq!( + app.paste_burst.on_char('b', now + Duration::from_millis(1)), + super::super::paste_burst::CharAction::Consumed + ); + assert_eq!( + app.paste_burst.on_char('c', now + Duration::from_millis(2)), + super::super::paste_burst::CharAction::RetroCapture(1) + ); + + app.needs_redraw = false; + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), + ); + + assert!(!app.needs_redraw); + assert!(app.input.is_empty()); + } + + #[test] + fn trusted_view_ignores_mouse_events() { + let mut app = make_test_app(); + app.active_view = ActiveView::Trusted; + app.viewport.scroll_target = 4; + app.selection = Some(SelectionState { + kind: SelectionKind::Chat, + start: SelectionPoint { row: 0, col: 0 }, + end: SelectionPoint { row: 0, col: 1 }, + dragging: false, + }); + + handle_terminal_event( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }), + ); + + assert_eq!(app.viewport.scroll_target, 4); + assert!(app.selection.is_some()); + } + + #[test] + fn internal_error_detection_accepts_xml_payload() { + use crate::agent::error_handling::looks_like_internal_error; + let payload = + "-32603Adapter process crashed"; + assert!(looks_like_internal_error(payload)); + } + + #[test] + fn internal_error_detection_rejects_plain_bash_failure() { + use crate::agent::error_handling::looks_like_internal_error; + let payload = "bash: unknown_command: command not found"; + assert!(!looks_like_internal_error(payload)); + } + + #[test] + fn summarize_internal_error_prefers_xml_message() { + use crate::agent::error_handling::summarize_internal_error; + let payload = + "-32603Adapter process crashed"; + assert_eq!(summarize_internal_error(payload), "Adapter process crashed"); + } + + #[test] + fn summarize_internal_error_reads_json_rpc_message() { + use crate::agent::error_handling::summarize_internal_error; + let payload = r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal rpc fault"}}"#; + assert_eq!(summarize_internal_error(payload), "internal rpc fault"); + } + + #[test] + fn internal_error_detection_accepts_permission_zod_payload() { + use crate::agent::error_handling::looks_like_internal_error; + let payload = "Tool permission request failed: ZodError: [{\"message\":\"Invalid input\"}]"; + assert!(looks_like_internal_error(payload)); + } + + #[test] + fn summarize_internal_error_prefers_permission_failure_summary() { + use crate::agent::error_handling::summarize_internal_error; + let payload = "Tool permission request failed: ZodError: [{\"message\":\"Invalid input: expected record, received undefined\"}]"; + assert_eq!( + summarize_internal_error(payload), + "Tool permission request failed: Invalid input: expected record, received undefined" + ); + } +} diff --git a/claude-code-rust/src/app/events/mouse.rs b/claude-code-rust/src/app/events/mouse.rs new file mode 100644 index 0000000..474da66 --- /dev/null +++ b/claude-code-rust/src/app/events/mouse.rs @@ -0,0 +1,225 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::super::selection::clear_selection; +use super::super::state::ScrollbarDragState; +use super::super::{App, SelectionKind, SelectionPoint}; +use crossterm::event::{MouseEvent, MouseEventKind}; + +pub(super) const MOUSE_SCROLL_LINES: usize = 3; +const SCROLLBAR_MIN_THUMB_HEIGHT: usize = 1; + +struct MouseSelectionPoint { + kind: SelectionKind, + point: SelectionPoint, +} + +pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + if start_scrollbar_drag(app, mouse) { + return; + } + app.scrollbar_drag = None; + if let Some(pt) = mouse_point_to_selection(app, mouse) { + app.selection = Some(super::super::SelectionState { + kind: pt.kind, + start: pt.point, + end: pt.point, + dragging: true, + }); + } else { + clear_selection(app); + } + } + MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { + if update_scrollbar_drag(app, mouse) { + return; + } + let pt = mouse_point_to_selection(app, mouse); + if let (Some(sel), Some(pt)) = (&mut app.selection, pt) { + sel.end = pt.point; + } + } + MouseEventKind::Up(crossterm::event::MouseButton::Left) => { + app.scrollbar_drag = None; + if let Some(sel) = &mut app.selection { + sel.dragging = false; + } + } + _ => {} + } + match mouse.kind { + MouseEventKind::ScrollUp => { + if app.selection.is_some() { + clear_selection(app); + } + app.viewport.scroll_up(MOUSE_SCROLL_LINES); + } + MouseEventKind::ScrollDown => { + if app.selection.is_some() { + clear_selection(app); + } + app.viewport.scroll_down(MOUSE_SCROLL_LINES); + } + _ => {} + } +} + +#[derive(Clone, Copy)] +pub(super) struct ScrollbarMetrics { + pub viewport_height: usize, + pub max_scroll: usize, + pub thumb_size: usize, + pub track_space: usize, +} + +fn start_scrollbar_drag(app: &mut App, mouse: MouseEvent) -> bool { + if !mouse_on_scrollbar_rail(app, mouse) { + return false; + } + let Some(metrics) = scrollbar_metrics(app) else { + return false; + }; + let Some(local_row) = mouse_row_on_chat_track(app, mouse) else { + return false; + }; + + let (thumb_top, thumb_size) = current_thumb_geometry(app, metrics); + let thumb_end = thumb_top.saturating_add(thumb_size); + let grab_offset = if (thumb_top..thumb_end).contains(&local_row) { + local_row.saturating_sub(thumb_top) + } else { + thumb_size / 2 + }; + + set_scroll_from_thumb_top(app, local_row.saturating_sub(grab_offset), metrics); + app.scrollbar_drag = Some(ScrollbarDragState { thumb_grab_offset: grab_offset }); + clear_selection(app); + true +} + +fn update_scrollbar_drag(app: &mut App, mouse: MouseEvent) -> bool { + let Some(drag) = app.scrollbar_drag else { + return false; + }; + let Some(metrics) = scrollbar_metrics(app) else { + app.scrollbar_drag = None; + return false; + }; + let Some(local_row) = mouse_row_on_chat_track(app, mouse) else { + return false; + }; + + set_scroll_from_thumb_top(app, local_row.saturating_sub(drag.thumb_grab_offset), metrics); + true +} + +fn scrollbar_metrics(app: &App) -> Option { + let area = app.rendered_chat_area; + if area.width == 0 || area.height == 0 { + return None; + } + + let viewport_height = area.height as usize; + let content_height = app.viewport.total_message_height(); + if content_height <= viewport_height { + return None; + } + + let max_scroll = content_height.saturating_sub(viewport_height); + let thumb_size = viewport_height + .saturating_mul(viewport_height) + .checked_div(content_height) + .unwrap_or(0) + .max(SCROLLBAR_MIN_THUMB_HEIGHT) + .min(viewport_height); + let track_space = viewport_height.saturating_sub(thumb_size); + + Some(ScrollbarMetrics { viewport_height, max_scroll, thumb_size, track_space }) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] +fn current_thumb_geometry(app: &App, metrics: ScrollbarMetrics) -> (usize, usize) { + let mut thumb_size = app.viewport.scrollbar_thumb_size.round() as usize; + if thumb_size == 0 { + thumb_size = metrics.thumb_size; + } + thumb_size = thumb_size.max(SCROLLBAR_MIN_THUMB_HEIGHT).min(metrics.viewport_height); + let max_top = metrics.viewport_height.saturating_sub(thumb_size); + let thumb_top = app.viewport.scrollbar_thumb_top.round().clamp(0.0, max_top as f32) as usize; + (thumb_top, thumb_size) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] +fn set_scroll_from_thumb_top(app: &mut App, thumb_top: usize, metrics: ScrollbarMetrics) { + let thumb_top = thumb_top.min(metrics.track_space); + let target = if metrics.track_space == 0 { + 0 + } else { + ((thumb_top as f32 / metrics.track_space as f32) * metrics.max_scroll as f32).round() + as usize + } + .min(metrics.max_scroll); + + app.viewport.auto_scroll = false; + app.viewport.scroll_target = target; + // Keep content movement responsive while dragging the thumb. + app.viewport.scroll_pos = target as f32; + app.viewport.scroll_offset = target; +} + +fn mouse_on_scrollbar_rail(app: &App, mouse: MouseEvent) -> bool { + let area = app.rendered_chat_area; + if area.width == 0 || area.height == 0 { + return false; + } + let rail_x = area.right().saturating_sub(1); + mouse.column == rail_x && mouse.row >= area.y && mouse.row < area.bottom() +} + +fn mouse_row_on_chat_track(app: &App, mouse: MouseEvent) -> Option { + let area = app.rendered_chat_area; + if area.height == 0 { + return None; + } + let max_row = area.height.saturating_sub(1) as usize; + if mouse.row < area.y { + return Some(0); + } + if mouse.row >= area.bottom() { + return Some(max_row); + } + Some((mouse.row - area.y) as usize) +} + +fn mouse_point_to_selection(app: &App, mouse: MouseEvent) -> Option { + let input_area = app.rendered_input_area; + if mouse.column >= input_area.x + && mouse.column < input_area.right() + && mouse.row >= input_area.y + && mouse.row < input_area.bottom() + { + let row = (mouse.row - input_area.y) as usize; + let col = (mouse.column - input_area.x) as usize; + return Some(MouseSelectionPoint { + kind: SelectionKind::Input, + point: SelectionPoint { row, col }, + }); + } + + let chat_area = app.rendered_chat_area; + if mouse.column >= chat_area.x + && mouse.column < chat_area.right() + && mouse.row >= chat_area.y + && mouse.row < chat_area.bottom() + { + let row = (mouse.row - chat_area.y) as usize; + let col = (mouse.column - chat_area.x) as usize; + return Some(MouseSelectionPoint { + kind: SelectionKind::Chat, + point: SelectionPoint { row, col }, + }); + } + None +} diff --git a/claude-code-rust/src/app/events/rate_limit.rs b/claude-code-rust/src/app/events/rate_limit.rs new file mode 100644 index 0000000..210c262 --- /dev/null +++ b/claude-code-rust/src/app/events/rate_limit.rs @@ -0,0 +1,129 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::super::{App, SystemSeverity}; +use crate::agent::model; + +fn format_rate_limit_type(raw: &str) -> &str { + match raw { + "five_hour" => "5-hour", + "daily" => "daily", + "minute" => "per-minute", + "seven_day" => "7-day", + "seven_day_opus" => "7-day Opus", + "seven_day_sonnet" => "7-day Sonnet", + "overage" => "overage", + other => other, + } +} + +/// Format an epoch timestamp as a countdown and UTC wall-clock: "4h 23m at 14:30 UTC". +fn format_resets_at(epoch_secs: f64) -> String { + use std::time::{Duration, UNIX_EPOCH}; + + let now = std::time::SystemTime::now(); + + let countdown = match (UNIX_EPOCH + Duration::from_secs_f64(epoch_secs)).duration_since(now) { + Ok(d) => { + let total_secs = d.as_secs(); + if total_secs < 60 { + "< 1 minute".to_owned() + } else { + let hours = total_secs / 3600; + let minutes = (total_secs % 3600) / 60; + if hours > 0 { format!("{hours}h {minutes}m") } else { format!("{minutes}m") } + } + } + Err(_) => "now".to_owned(), + }; + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let epoch_u64 = epoch_secs.max(0.0) as u64; + let h = (epoch_u64 % 86400) / 3600; + let m = (epoch_u64 % 3600) / 60; + + format!("{countdown} at {h:02}:{m:02} UTC") +} + +pub(super) fn format_rate_limit_summary(update: &model::RateLimitUpdate) -> String { + let is_rejected = matches!(update.status, model::RateLimitStatus::Rejected); + + // Intro + let intro = if is_rejected { "Rate limit reached" } else { "Approaching rate limit" }; + + // "you've used 91% of your 5-hour rate limit" + let usage_part = match (update.utilization, &update.rate_limit_type) { + (Some(util), Some(rlt)) => { + format!( + "you've used {:.0}% of your {} rate limit", + util * 100.0, + format_rate_limit_type(rlt), + ) + } + (Some(util), None) => format!("you've used {:.0}% of your rate limit", util * 100.0), + (None, Some(rlt)) => { + format!("you've hit your {} rate limit", format_rate_limit_type(rlt)) + } + (None, None) => "you've hit your rate limit".to_owned(), + }; + + let mut message = format!("{intro}, {usage_part}."); + + // Overage hint + if is_rejected { + // Rejected: state if overage is in use + if update.is_using_overage == Some(true) { + message.push_str(" You are using your overage allowance."); + } + } else { + // Warning: hint that overage is available + if update.is_using_overage == Some(false) || update.overage_status.is_some() { + message.push_str(" You can continue using your overage allowance."); + } + } + + // Resets in X at HH:MM + if let Some(resets_at) = update.resets_at { + use std::fmt::Write; + let _ = write!(message, " Resets in {}.", format_resets_at(resets_at)); + } + + message +} + +pub(super) fn handle_rate_limit_update(app: &mut App, update: &model::RateLimitUpdate) { + let previous_status = app.last_rate_limit_update.as_ref().map(|existing| existing.status); + app.last_rate_limit_update = Some(update.clone()); + + match update.status { + model::RateLimitStatus::Allowed => {} + model::RateLimitStatus::AllowedWarning => { + if previous_status == Some(model::RateLimitStatus::AllowedWarning) { + return; + } + let summary = format_rate_limit_summary(update); + super::push_system_message_with_severity(app, Some(SystemSeverity::Warning), &summary); + } + model::RateLimitStatus::Rejected => { + let summary = format_rate_limit_summary(update); + super::push_system_message_with_severity(app, None, &summary); + } + } +} + +pub(super) fn handle_compaction_boundary_update( + app: &mut App, + boundary: model::CompactionBoundary, +) { + app.is_compacting = true; + if matches!(boundary.trigger, model::CompactionTrigger::Manual) { + app.pending_compact_clear = true; + } + app.session_usage.last_compaction_trigger = Some(boundary.trigger); + app.session_usage.last_compaction_pre_tokens = Some(boundary.pre_tokens); + tracing::debug!( + "CompactionBoundary: trigger={:?} pre_tokens={}", + boundary.trigger, + boundary.pre_tokens + ); +} diff --git a/claude-code-rust/src/app/events/session.rs b/claude-code-rust/src/app/events/session.rs new file mode 100644 index 0000000..418f8b8 --- /dev/null +++ b/claude-code-rust/src/app/events/session.rs @@ -0,0 +1,310 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::super::connect::take_connection_slot; +use super::super::connect::{SessionStartReason, start_new_session}; +use super::super::state::RecentSessionInfo; +use super::super::{ + App, AppStatus, ChatMessage, InvalidationLevel, LoginHint, MessageBlock, MessageRole, + SystemSeverity, TextBlock, +}; +use super::push_system_message_with_severity; +use super::session_reset::{load_resume_history, reset_for_new_session}; +use crate::agent::client::AgentConnection; +use crate::agent::events::ServiceStatusSeverity; +use crate::agent::model; +use crate::error::AppError; +use std::rc::Rc; + +const TURN_ERROR_INPUT_LOCK_HINT: &str = + "Input disabled after an error. Press Ctrl+Q to quit and try again."; + +pub(super) fn handle_connected_client_event( + app: &mut App, + session_id: model::SessionId, + cwd: String, + model_name: String, + available_models: Vec, + mode: Option, + history_updates: &[model::SessionUpdate], +) { + if let Some(slot) = take_connection_slot() { + app.conn = Some(slot.conn); + } + apply_session_cwd(app, cwd); + reset_for_new_session(app, session_id, model_name, mode); + app.available_models = available_models; + app.update_welcome_model_once(); + app.sync_welcome_recent_sessions(); + if !history_updates.is_empty() { + load_resume_history(app, history_updates); + } + clear_pending_command(app); + app.resuming_session_id = None; + app.rebuild_chat_focus_from_state(); + crate::app::config::refresh_runtime_tabs_for_session_change(app); +} + +pub(super) fn handle_sessions_listed_event( + app: &mut App, + sessions: Vec, +) { + let pending_title_change = app.config.pending_session_title_change.take(); + app.recent_sessions = sessions + .into_iter() + .map(|entry| RecentSessionInfo { + session_id: entry.session_id, + summary: entry.summary, + last_modified_ms: entry.last_modified_ms, + file_size_bytes: entry.file_size_bytes, + cwd: entry.cwd, + git_branch: entry.git_branch, + custom_title: entry.custom_title, + first_prompt: entry.first_prompt, + }) + .collect(); + if let Some(pending_title_change) = pending_title_change { + let renamed_session_present = app + .recent_sessions + .iter() + .any(|session| session.session_id == pending_title_change.session_id); + if renamed_session_present { + app.config.last_error = None; + app.config.status_message = Some(match pending_title_change.kind { + crate::app::config::PendingSessionTitleChangeKind::Rename { requested_title } => { + match requested_title { + Some(title) => format!("Renamed session to {title}"), + None => "Cleared session name".to_owned(), + } + } + crate::app::config::PendingSessionTitleChangeKind::Generate => { + "Generated session title".to_owned() + } + }); + } + } + app.sync_welcome_recent_sessions(); +} + +pub(super) fn handle_auth_required_event( + app: &mut App, + method_name: String, + method_description: String, +) { + clear_pending_command(app); + app.resuming_session_id = None; + app.login_hint = Some(LoginHint { method_name, method_description }); + app.bump_session_scope_epoch(); + app.clear_session_runtime_identity(); + super::clear_compaction_state(app, false); + app.last_rate_limit_update = None; + app.cancelled_turn_pending_hint = false; + app.pending_cancel_origin = None; + app.pending_auto_submit_after_cancel = false; + app.account_info = None; + app.mcp = super::super::McpState::default(); + app.config.pending_session_title_change = None; + crate::app::usage::reset_for_session_change(app); + app.finalize_turn_runtime_artifacts(model::ToolCallStatus::Failed); + app.clear_active_turn_assistant(); +} + +pub(super) fn handle_connection_failed_event(app: &mut App, msg: &str) { + app.bump_session_scope_epoch(); + app.clear_session_runtime_identity(); + super::clear_compaction_state(app, false); + app.cancelled_turn_pending_hint = false; + app.pending_cancel_origin = None; + app.pending_auto_submit_after_cancel = false; + app.last_rate_limit_update = None; + app.account_info = None; + app.mcp = super::super::McpState::default(); + app.config.pending_session_title_change = None; + crate::app::usage::reset_for_session_change(app); + app.resuming_session_id = None; + app.pending_command_label = None; + app.pending_command_ack = None; + app.finalize_turn_runtime_artifacts(model::ToolCallStatus::Failed); + app.input.clear(); + app.pending_submit = None; + app.status = AppStatus::Error; + app.clear_active_turn_assistant(); + push_connection_error_message(app, msg); +} + +pub(super) fn handle_slash_command_error_event(app: &mut App, msg: &str) { + if app.config.pending_session_title_change.take().is_some() { + app.config.last_error = Some(msg.to_owned()); + app.config.status_message = None; + app.needs_redraw = true; + return; + } + app.push_message_tracked(ChatMessage { + role: MessageRole::System(None), + blocks: vec![MessageBlock::Text(TextBlock::from_complete(msg))], + usage: None, + }); + app.enforce_history_retention_tracked(); + app.viewport.engage_auto_scroll(); + clear_pending_command(app); + app.resuming_session_id = None; +} + +pub(super) fn handle_auth_completed_event(app: &mut App, conn: &Rc) { + tracing::info!("Authentication completed via /login"); + app.login_hint = None; + app.pending_command_label = Some("Starting session...".to_owned()); + app.pending_command_ack = None; + push_system_message_with_severity( + app, + Some(SystemSeverity::Info), + "Authentication successful. Starting new session...", + ); + app.force_redraw = true; + + if let Err(e) = start_new_session(app, conn, SessionStartReason::Login) { + clear_pending_command(app); + push_system_message_with_severity( + app, + Some(SystemSeverity::Error), + &format!("Failed to start session after login: {e}"), + ); + } +} + +pub(super) fn handle_logout_completed_event(app: &mut App) { + tracing::info!("Logout completed via /logout"); + // Clear the session and start a new one. The bridge now checks auth + // during initialization and will fire AuthRequired immediately. + app.bump_session_scope_epoch(); + app.clear_session_runtime_identity(); + app.account_info = None; + app.mcp = super::super::McpState::default(); + app.config.pending_session_title_change = None; + crate::app::usage::reset_for_session_change(app); + app.force_redraw = true; + + if let Some(ref conn) = app.conn { + app.pending_command_label = Some("Starting session...".to_owned()); + app.pending_command_ack = None; + if let Err(e) = start_new_session(app, conn, SessionStartReason::Logout) { + clear_pending_command(app); + push_system_message_with_severity( + app, + Some(SystemSeverity::Error), + &format!("Failed to start new session after logout: {e}"), + ); + } + } else { + tracing::warn!("No connection available after logout; cannot start new session"); + clear_pending_command(app); + push_system_message_with_severity( + app, + Some(SystemSeverity::Warning), + "Logged out, but no connection available to start a new session.", + ); + } +} + +pub(super) fn handle_session_replaced_event( + app: &mut App, + session_id: model::SessionId, + cwd: String, + model_name: String, + available_models: Vec, + mode: Option, + history_updates: &[model::SessionUpdate], +) { + super::clear_compaction_state(app, false); + app.pending_cancel_origin = None; + app.pending_auto_submit_after_cancel = false; + apply_session_cwd(app, cwd); + app.available_models = available_models; + reset_for_new_session(app, session_id, model_name, mode); + if !history_updates.is_empty() { + load_resume_history(app, history_updates); + } + clear_pending_command(app); + app.resuming_session_id = None; + crate::app::config::refresh_runtime_tabs_for_session_change(app); +} + +pub(super) fn handle_update_available_event( + app: &mut App, + latest_version: &str, + current_version: &str, +) { + app.update_check_hint = Some(format!( + "Update available: v{latest_version} (current v{current_version}) Ctrl+U to hide" + )); +} + +pub(super) fn handle_service_status_event( + app: &mut App, + severity: ServiceStatusSeverity, + message: &str, +) { + let ui_severity = match severity { + ServiceStatusSeverity::Warning => SystemSeverity::Warning, + ServiceStatusSeverity::Error => SystemSeverity::Error, + }; + push_system_message_with_severity(app, Some(ui_severity), message); +} + +pub(super) fn handle_fatal_error_event(app: &mut App, error: AppError) { + app.finalize_turn_runtime_artifacts(model::ToolCallStatus::Failed); + app.clear_active_turn_assistant(); + app.exit_error = Some(error); + app.should_quit = true; + app.status = AppStatus::Error; + app.pending_submit = None; + app.pending_command_label = None; + app.pending_command_ack = None; +} + +/// Clear the `CommandPending` state and restore `Ready`. +pub(super) fn clear_pending_command(app: &mut App) { + app.pending_command_label = None; + app.pending_command_ack = None; + app.status = AppStatus::Ready; +} + +fn push_connection_error_message(app: &mut App, error: &str) { + let message = format!("Connection failed: {error}\n\n{TURN_ERROR_INPUT_LOCK_HINT}"); + push_system_message_with_severity(app, None, &message); +} + +fn shorten_cwd_display(cwd_raw: &str) -> String { + if let Some(home) = dirs::home_dir() { + let home_str = home.to_string_lossy(); + if cwd_raw.starts_with(home_str.as_ref()) { + return format!("~{}", &cwd_raw[home_str.len()..]); + } + } + cwd_raw.to_owned() +} + +fn sync_welcome_cwd(app: &mut App) { + let Some(first) = app.messages.first_mut() else { + return; + }; + if !matches!(first.role, MessageRole::Welcome) { + return; + } + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first_mut() else { + return; + }; + welcome.cwd.clone_from(&app.cwd); + welcome.cache.invalidate(); + app.sync_render_cache_slot(0, 0); + app.recompute_message_retained_bytes(0); + app.invalidate_layout(InvalidationLevel::MessagesFrom(0)); +} + +pub(super) fn apply_session_cwd(app: &mut App, cwd_raw: String) { + app.cwd_raw = cwd_raw; + app.cwd = shorten_cwd_display(&app.cwd_raw); + app.refresh_git_branch(); + sync_welcome_cwd(app); + app.reconcile_trust_state_from_preferences_and_cwd(); +} diff --git a/claude-code-rust/src/app/events/session_reset.rs b/claude-code-rust/src/app/events/session_reset.rs new file mode 100644 index 0000000..e59220b --- /dev/null +++ b/claude-code-rust/src/app/events/session_reset.rs @@ -0,0 +1,182 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::super::{ + App, BlockCache, ChatMessage, IncrementalMarkdown, MessageBlock, MessageRole, TextBlock, + TextBlockSpacing, +}; +use crate::agent::model; + +pub(super) fn reset_for_new_session( + app: &mut App, + session_id: model::SessionId, + model_name: String, + mode: Option, +) { + crate::agent::events::kill_all_terminals(&app.terminals); + + reset_session_identity_state(app, session_id, model_name, mode); + reset_messages_for_new_session(app); + reset_input_state_for_new_session(app); + reset_interaction_state_for_new_session(app); + reset_render_state_for_new_session(app); + reset_cache_and_footer_state_for_new_session(app); + app.refresh_git_branch(); +} + +fn reset_session_identity_state( + app: &mut App, + session_id: model::SessionId, + model_name: String, + mode: Option, +) { + app.bump_session_scope_epoch(); + app.session_id = Some(session_id); + app.model_name = model_name; + app.mode = mode; + app.config_options.clear(); + app.config_options + .insert("model".to_owned(), serde_json::Value::String(app.model_name.clone())); + app.login_hint = None; + super::clear_compaction_state(app, false); + app.session_usage = super::super::SessionUsageState::default(); + app.fast_mode_state = model::FastModeState::Off; + app.last_rate_limit_update = None; + app.should_quit = false; + app.files_accessed = 0; + app.cancelled_turn_pending_hint = false; + app.pending_cancel_origin = None; + app.pending_auto_submit_after_cancel = false; + app.account_info = None; +} + +fn reset_messages_for_new_session(app: &mut App) { + app.clear_messages_tracked(); + app.history_retention_stats = super::super::state::HistoryRetentionStats::default(); + app.welcome_model_resolved = false; + app.push_message_tracked(ChatMessage::welcome_with_recent( + app.model_display_name(), + &app.cwd, + &app.recent_sessions, + )); + app.update_welcome_model_once(); + app.viewport = super::super::ChatViewport::new(); +} + +fn reset_input_state_for_new_session(app: &mut App) { + app.input.clear(); + app.help_open = false; + app.pending_submit = None; + app.pending_paste_text.clear(); + app.pending_paste_session = None; + app.active_paste_session = None; +} + +fn reset_interaction_state_for_new_session(app: &mut App) { + app.pending_interaction_ids.clear(); + app.clear_tool_scope_tracking(); + app.tool_call_index.clear(); + app.todos.clear(); + app.show_todo_panel = false; + app.todo_scroll = 0; + app.todo_selected = 0; + app.focus = super::super::FocusManager::default(); + app.available_commands.clear(); + app.available_agents.clear(); + app.config.overlay = None; + app.config.pending_session_title_change = None; +} + +fn reset_render_state_for_new_session(app: &mut App) { + app.selection = None; + app.scrollbar_drag = None; + app.rendered_chat_lines.clear(); + app.rendered_chat_area = ratatui::layout::Rect::default(); + app.rendered_input_lines.clear(); + app.rendered_input_area = ratatui::layout::Rect::default(); + app.mention = None; + app.slash = None; + app.subagent = None; + app.help_view = super::super::HelpView::default(); + app.help_dialog = crate::app::dialog::DialogState::default(); + app.help_visible_count = 0; +} + +fn reset_cache_and_footer_state_for_new_session(app: &mut App) { + app.cached_todo_compact = None; + app.clear_terminal_tool_call_tracking(); + app.mcp = super::super::McpState::default(); + crate::app::usage::reset_for_session_change(app); + crate::app::plugins::reset_for_session_change(app); + app.force_redraw = true; + app.needs_redraw = true; +} + +fn append_resume_user_message_chunk(app: &mut App, chunk: &model::ContentChunk) { + let model::ContentBlock::Text(text) = &chunk.content else { + return; + }; + if text.text.is_empty() { + return; + } + + if let Some(last) = app.messages.last_mut() + && matches!(last.role, MessageRole::User) + { + if let Some(MessageBlock::Text(block)) = last.blocks.last_mut() { + block.text.push_str(&text.text); + block.markdown.append(&text.text); + block.cache.invalidate(); + } else { + let mut incr = IncrementalMarkdown::default(); + incr.append(&text.text); + last.blocks.push(MessageBlock::Text(TextBlock { + text: text.text.clone(), + cache: BlockCache::default(), + markdown: incr, + trailing_spacing: TextBlockSpacing::default(), + })); + } + let last_idx = app.messages.len().saturating_sub(1); + app.sync_after_message_blocks_changed(last_idx); + return; + } + + let mut incr = IncrementalMarkdown::default(); + incr.append(&text.text); + app.push_message_tracked(ChatMessage { + role: MessageRole::User, + blocks: vec![MessageBlock::Text(TextBlock { + text: text.text.clone(), + cache: BlockCache::default(), + markdown: incr, + trailing_spacing: TextBlockSpacing::default(), + })], + usage: None, + }); +} + +pub(super) fn load_resume_history(app: &mut App, history_updates: &[model::SessionUpdate]) { + app.clear_messages_tracked(); + app.history_retention_stats = super::super::state::HistoryRetentionStats::default(); + app.welcome_model_resolved = false; + app.push_message_tracked(ChatMessage::welcome_with_recent( + app.model_display_name(), + &app.cwd, + &app.recent_sessions, + )); + app.update_welcome_model_once(); + for update in history_updates { + match update { + model::SessionUpdate::UserMessageChunk(chunk) => { + append_resume_user_message_chunk(app, chunk); + } + _ => super::handle_session_update(app, update.clone()), + } + } + app.finalize_turn_runtime_artifacts(model::ToolCallStatus::Failed); + app.clear_active_turn_assistant(); + app.enforce_history_retention_tracked(); + app.viewport = super::super::ChatViewport::new(); + app.viewport.engage_auto_scroll(); +} diff --git a/claude-code-rust/src/app/events/streaming.rs b/claude-code-rust/src/app/events/streaming.rs new file mode 100644 index 0000000..e7873ea --- /dev/null +++ b/claude-code-rust/src/app/events/streaming.rs @@ -0,0 +1,120 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::super::{ + App, AppStatus, ChatMessage, MessageBlock, MessageRole, TextBlock, TextBlockSpacing, + TextSplitDecision, TextSplitKind, default_cache_split_policy, find_text_split, +}; +use crate::agent::model; + +pub(super) fn handle_agent_message_chunk(app: &mut App, chunk: model::ContentChunk) { + let model::ContentBlock::Text(text) = chunk.content else { + return; + }; + + app.status = AppStatus::Running; + if text.text.is_empty() { + return; + } + if let Some(owner_idx) = app.active_turn_assistant_idx() + && let Some(owner) = app.messages.get_mut(owner_idx) + { + append_agent_stream_text(&mut owner.blocks, &text.text); + app.sync_after_message_blocks_changed(owner_idx); + return; + } + + if let Some(last) = app.messages.last_mut() + && matches!(last.role, MessageRole::Assistant) + { + append_agent_stream_text(&mut last.blocks, &text.text); + let last_idx = app.messages.len().saturating_sub(1); + app.bind_active_turn_assistant(last_idx); + app.sync_after_message_blocks_changed(last_idx); + return; + } + + let mut blocks = Vec::new(); + append_agent_stream_text(&mut blocks, &text.text); + app.push_message_tracked(ChatMessage { role: MessageRole::Assistant, blocks, usage: None }); + app.bind_active_turn_assistant_to_tail(); +} + +pub(super) fn append_agent_stream_text(blocks: &mut Vec, chunk: &str) { + if chunk.is_empty() { + return; + } + if let Some(MessageBlock::Text(block)) = blocks.last_mut() { + block.text.push_str(chunk); + block.markdown.append(chunk); + block.cache.invalidate(); + } else { + blocks.push(new_text_block(chunk.to_owned())); + } + + let split_count = split_tail_text_block(blocks); + if split_count > 0 { + crate::perf::mark_with("text_block_split_count", "count", split_count); + } + + if let Some(MessageBlock::Text(block)) = blocks.last() { + crate::perf::mark_with("text_block_active_tail_bytes", "bytes", block.text.len()); + } + let text_block_count = blocks.iter().filter(|b| matches!(b, MessageBlock::Text(..))).count(); + crate::perf::mark_with("text_block_frozen_count", "count", text_block_count.saturating_sub(1)); +} + +fn new_text_block(text: String) -> MessageBlock { + MessageBlock::Text(TextBlock::new(text)) +} + +fn split_tail_text_block(blocks: &mut Vec) -> usize { + let mut split_count = 0usize; + loop { + let Some(tail_idx) = blocks.len().checked_sub(1) else { + break; + }; + let Some(split) = blocks.get(tail_idx).and_then(|block| { + if let MessageBlock::Text(block) = block { + find_text_block_split(block.text.as_str()) + } else { + None + } + }) else { + break; + }; + + let (completed, remainder) = match blocks.get(tail_idx) { + Some(MessageBlock::Text(block)) => { + (block.text[..split.split_at].to_owned(), block.text[split.split_at..].to_owned()) + } + _ => break, + }; + + if completed.is_empty() || remainder.is_empty() { + break; + } + + blocks[tail_idx] = new_text_block(remainder); + blocks.insert(tail_idx, completed_text_block(completed, split)); + split_count += 1; + } + split_count +} + +fn completed_text_block(text: String, split: TextSplitDecision) -> MessageBlock { + let trailing_spacing = match split.kind { + TextSplitKind::Generic => TextBlockSpacing::None, + TextSplitKind::ParagraphBoundary => TextBlockSpacing::ParagraphBreak, + }; + MessageBlock::Text(TextBlock::new(text).with_trailing_spacing(trailing_spacing)) +} + +pub(super) fn find_text_block_split(text: &str) -> Option { + find_text_split(text, *default_cache_split_policy()) +} + +#[cfg(test)] +pub(super) fn find_text_block_split_index(text: &str) -> Option { + find_text_block_split(text).map(|decision| decision.split_at) +} diff --git a/claude-code-rust/src/app/events/tool_calls.rs b/claude-code-rust/src/app/events/tool_calls.rs new file mode 100644 index 0000000..7aae9ff --- /dev/null +++ b/claude-code-rust/src/app/events/tool_calls.rs @@ -0,0 +1,376 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::super::{ + App, AppStatus, BlockCache, ChatMessage, InvalidationLevel, MessageBlock, MessageRole, + ToolCallInfo, ToolCallScope, +}; +use super::tool_updates::raw_output_to_terminal_text; +use crate::agent::model; +use crate::app::todos::{parse_todos_if_present, set_todos}; +use std::time::Instant; + +pub(super) fn handle_tool_call(app: &mut App, tc: model::ToolCall) { + log_tool_call_received(&tc); + let id_str = tc.tool_call_id.clone(); + let sdk_tool_name = resolve_sdk_tool_name(tc.kind, tc.meta.as_ref()); + let scope = register_tool_call_scope(app, &id_str, &sdk_tool_name); + maybe_apply_todo_write_from_tool_call(app, &id_str, &sdk_tool_name, tc.raw_input.as_ref()); + update_subagent_scope_state(app, scope, tc.status, &id_str); + + let tool_info = build_tool_info_from_tool_call(app, tc, sdk_tool_name); + if should_jump_on_large_write(&tool_info) { + app.viewport.engage_auto_scroll(); + } + upsert_tool_call_into_assistant_message(app, tool_info); + + app.status = AppStatus::Running; + app.files_accessed += 1; +} + +fn log_tool_call_received(tc: &model::ToolCall) { + let id_str = tc.tool_call_id.clone(); + let title = tc.title.clone(); + let kind = tc.kind; + tracing::debug!( + "ToolCall: id={id_str} title={title} kind={kind:?} status={:?} content_blocks={} has_raw_output={}", + tc.status, + tc.content.len(), + tc.raw_output.is_some() + ); +} + +pub(super) fn register_tool_call_scope( + app: &mut App, + id: &str, + sdk_tool_name: &str, +) -> ToolCallScope { + // TODO: When the bridge exposes an explicit Task/Agent <-> child-tool relation, + // redesign subagent rendering so the parent Task/Agent summary block becomes + // the primary visible surface and child agent tools are not rendered directly. + let is_task = matches!(sdk_tool_name, "Task" | "Agent"); + let scope = if is_task { + ToolCallScope::Task + } else if app.active_task_ids.is_empty() { + ToolCallScope::MainAgent + } else { + ToolCallScope::Subagent + }; + app.register_tool_call_scope(id.to_owned(), scope); + if is_task { + app.insert_active_task(id.to_owned()); + } + scope +} + +fn maybe_apply_todo_write_from_tool_call( + app: &mut App, + id: &str, + sdk_tool_name: &str, + raw_input: Option<&serde_json::Value>, +) { + if sdk_tool_name != "TodoWrite" { + return; + } + tracing::info!("TodoWrite ToolCall detected: id={id}, raw_input={raw_input:?}"); + if let Some(raw_input) = raw_input { + if let Some(todos) = parse_todos_if_present(raw_input) { + tracing::info!("Parsed {} todos from ToolCall raw_input", todos.len()); + set_todos(app, todos); + } else { + tracing::debug!( + "TodoWrite ToolCall raw_input has no todos array yet; preserving existing todos" + ); + } + } else { + tracing::warn!("TodoWrite ToolCall has no raw_input"); + } +} + +pub(super) fn update_subagent_scope_state( + app: &mut App, + scope: ToolCallScope, + status: model::ToolCallStatus, + id: &str, +) { + match (scope, status) { + ( + ToolCallScope::Subagent, + model::ToolCallStatus::InProgress | model::ToolCallStatus::Pending, + ) => app.mark_subagent_tool_started(id), + ( + ToolCallScope::Subagent, + model::ToolCallStatus::Completed | model::ToolCallStatus::Failed, + ) => app.mark_subagent_tool_finished(id, Instant::now()), + _ => app.refresh_subagent_idle_since(Instant::now()), + } +} + +fn build_tool_info_from_tool_call( + app: &App, + tc: model::ToolCall, + sdk_tool_name: String, +) -> ToolCallInfo { + let terminal_id = tc.content.iter().find_map(|content| match content { + model::ToolCallContent::Terminal(term) => Some(term.terminal_id.clone()), + _ => None, + }); + let terminal_command = terminal_id.as_ref().and_then(|terminal_id| { + app.terminals.borrow().get(terminal_id).map(|terminal| terminal.command.clone()) + }); + let initial_execute_output = if super::super::is_execute_tool_name(&sdk_tool_name) { + tc.raw_output.as_ref().and_then(raw_output_to_terminal_text) + } else { + None + }; + + let mut tool_info = ToolCallInfo { + id: tc.tool_call_id, + title: shorten_tool_title(&tc.title, &app.cwd_raw), + sdk_tool_name, + raw_input: tc.raw_input, + raw_input_bytes: 0, + output_metadata: tc.output_metadata, + status: tc.status, + content: tc.content, + hidden: false, + terminal_id, + terminal_command, + terminal_output: None, + terminal_output_len: 0, + terminal_bytes_seen: 0, + terminal_snapshot_mode: crate::app::TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + }; + tool_info.raw_input_bytes = + tool_info.raw_input.as_ref().map_or(0, ToolCallInfo::estimate_json_value_bytes); + if let Some(output) = initial_execute_output { + tool_info.terminal_output_len = output.len(); + tool_info.terminal_bytes_seen = output.len(); + tool_info.terminal_output = Some(output); + tool_info.terminal_snapshot_mode = crate::app::TerminalSnapshotMode::ReplaceSnapshot; + } + tool_info +} + +pub(super) fn upsert_tool_call_into_assistant_message(app: &mut App, tool_info: ToolCallInfo) { + let existing_pos = app.lookup_tool_call(&tool_info.id); + + if let Some((mi, bi)) = existing_pos { + update_existing_tool_call(app, mi, bi, &tool_info); + return; + } + + if let Some(msg_idx) = app.active_turn_assistant_idx() + && let Some(owner) = app.messages.get_mut(msg_idx) + { + let block_idx = owner.blocks.len(); + let tc_id = tool_info.id.clone(); + let terminal_id = App::tracked_terminal_id_for_tool(&tool_info); + owner.blocks.push(MessageBlock::ToolCall(Box::new(tool_info))); + app.sync_after_message_blocks_changed(msg_idx); + app.index_tool_call(tc_id, msg_idx, block_idx); + sync_tool_call_terminal_tracking(app, msg_idx, block_idx, terminal_id); + return; + } + + let msg_idx = app.messages.len().saturating_sub(1); + if app.messages.last().is_some_and(|m| matches!(m.role, MessageRole::Assistant)) { + if let Some(last) = app.messages.last_mut() { + let block_idx = last.blocks.len(); + let tc_id = tool_info.id.clone(); + let terminal_id = App::tracked_terminal_id_for_tool(&tool_info); + last.blocks.push(MessageBlock::ToolCall(Box::new(tool_info))); + app.bind_active_turn_assistant(msg_idx); + app.sync_after_message_blocks_changed(msg_idx); + app.index_tool_call(tc_id, msg_idx, block_idx); + sync_tool_call_terminal_tracking(app, msg_idx, block_idx, terminal_id); + } + } else { + let tc_id = tool_info.id.clone(); + let terminal_id = App::tracked_terminal_id_for_tool(&tool_info); + let new_idx = app.messages.len(); + app.push_message_tracked(ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(tool_info))], + usage: None, + }); + app.bind_active_turn_assistant(new_idx); + app.index_tool_call(tc_id, new_idx, 0); + sync_tool_call_terminal_tracking(app, new_idx, 0, terminal_id); + } +} + +fn update_existing_tool_call(app: &mut App, mi: usize, bi: usize, tool_info: &ToolCallInfo) { + let mut layout_dirty = false; + let mut terminal_tracking = None; + if let Some(MessageBlock::ToolCall(existing)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + { + let existing = existing.as_mut(); + let mut changed = false; + changed |= sync_if_changed(&mut existing.title, &tool_info.title); + changed |= sync_if_changed(&mut existing.status, &tool_info.status); + changed |= sync_if_changed(&mut existing.content, &tool_info.content); + changed |= sync_if_changed(&mut existing.sdk_tool_name, &tool_info.sdk_tool_name); + changed |= existing.set_raw_input(tool_info.raw_input.clone()); + changed |= sync_if_changed(&mut existing.output_metadata, &tool_info.output_metadata); + if tool_info.terminal_id.is_some() { + changed |= sync_if_changed(&mut existing.terminal_id, &tool_info.terminal_id); + } + if tool_info.terminal_command.is_some() { + changed |= sync_if_changed(&mut existing.terminal_command, &tool_info.terminal_command); + } + if tool_info.terminal_output.is_some() { + changed |= sync_if_changed(&mut existing.terminal_output, &tool_info.terminal_output); + changed |= + sync_if_changed(&mut existing.terminal_output_len, &tool_info.terminal_output_len); + changed |= + sync_if_changed(&mut existing.terminal_bytes_seen, &tool_info.terminal_bytes_seen); + changed |= sync_if_changed( + &mut existing.terminal_snapshot_mode, + &tool_info.terminal_snapshot_mode, + ); + } + if changed { + existing.mark_tool_call_layout_dirty(); + layout_dirty = true; + } else { + crate::perf::mark("tool_update_noop_skips"); + } + terminal_tracking = Some(App::tracked_terminal_id_for_tool(existing)); + } + sync_tool_call_terminal_tracking(app, mi, bi, terminal_tracking.flatten()); + if layout_dirty { + app.sync_render_cache_slot(mi, bi); + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } +} + +fn sync_tool_call_terminal_tracking( + app: &mut App, + msg_idx: usize, + block_idx: usize, + terminal_id: Option, +) { + if let Some(terminal_id) = terminal_id { + app.sync_terminal_tool_call(terminal_id, msg_idx, block_idx); + } else { + app.untrack_terminal_tool_call(msg_idx, block_idx); + } +} + +pub(super) fn sync_if_changed(dst: &mut T, src: &T) -> bool { + if dst == src { + return false; + } + dst.clone_from(src); + true +} + +pub(super) fn sdk_tool_name_from_meta(meta: Option<&serde_json::Value>) -> Option<&str> { + meta.and_then(|m| m.get("claudeCode")).and_then(|v| v.get("toolName")).and_then(|v| v.as_str()) +} + +fn fallback_sdk_tool_name(kind: model::ToolKind) -> &'static str { + match kind { + model::ToolKind::Read => "Read", + model::ToolKind::Edit => "Edit", + model::ToolKind::Delete => "Delete", + model::ToolKind::Move => "Move", + model::ToolKind::Search => "Search", + model::ToolKind::Execute => "Bash", + model::ToolKind::Think => "Think", + model::ToolKind::Fetch => "Fetch", + model::ToolKind::SwitchMode => "ExitPlanMode", + model::ToolKind::Other => "Tool", + } +} + +pub(super) fn resolve_sdk_tool_name( + kind: model::ToolKind, + meta: Option<&serde_json::Value>, +) -> String { + if let Some(name) = sdk_tool_name_from_meta(meta).filter(|name| !name.trim().is_empty()) { + name.to_owned() + } else { + let fallback = fallback_sdk_tool_name(kind); + if matches!(kind, model::ToolKind::Think) { + tracing::warn!( + "ToolKind::Think tool arrived with no meta.claudeCode.toolName -- \ + Task/Agent scope detection may be incorrect; falling back to '{fallback}'" + ); + } + fallback.to_owned() + } +} + +/// Shorten absolute paths in tool titles to relative paths based on cwd. +/// e.g. "Read C:\\Users\\me\\project\\src\\main.rs" -> "Read src/main.rs" +/// Handles both `/` and `\\` separators on all platforms since the bridge adapter +/// may use either regardless of the host OS. +pub(super) fn shorten_tool_title(title: &str, cwd_raw: &str) -> String { + if cwd_raw.is_empty() { + return title.to_owned(); + } + + // Quick check: if title doesn't contain any part of cwd, skip normalization + // Use the first path component of cwd as a heuristic + let cwd_start = cwd_raw.split(['/', '\\']).find(|s| !s.is_empty()).unwrap_or(cwd_raw); + if !title.contains(cwd_start) { + return title.to_owned(); + } + + // Normalize both to forward slashes for matching + let cwd_norm = cwd_raw.replace('\\', "/"); + let title_norm = title.replace('\\', "/"); + + // Ensure cwd ends with slash so we strip the separator too + let with_sep = if cwd_norm.ends_with('/') { cwd_norm } else { format!("{cwd_norm}/") }; + + if title_norm.contains(&with_sep) { + return title_norm.replace(&with_sep, ""); + } + title_norm +} + +pub(super) const WRITE_DIFF_JUMP_THRESHOLD_LINES: usize = 40; + +pub(super) fn should_jump_on_large_write(tc: &ToolCallInfo) -> bool { + if tc.sdk_tool_name != "Write" { + return false; + } + tc.content.iter().any(|c| match c { + model::ToolCallContent::Diff(diff) => { + let new_lines = diff.new_text.lines().count(); + let old_lines = diff.old_text.as_deref().map_or(0, |t| t.lines().count()); + new_lines.max(old_lines) >= WRITE_DIFF_JUMP_THRESHOLD_LINES + } + _ => false, + }) +} + +/// Check if any tool call in the current assistant message is still in-progress. +pub(super) fn has_in_progress_tool_calls(app: &App) -> bool { + if let Some(owner_idx) = app.active_turn_assistant_idx() + && let Some(owner) = app.messages.get(owner_idx) + { + return owner.blocks.iter().any(|block| { + matches!( + block, + MessageBlock::ToolCall(tc) + if matches!(tc.status, model::ToolCallStatus::InProgress | model::ToolCallStatus::Pending) + ) + }); + } + false +} diff --git a/claude-code-rust/src/app/events/tool_updates.rs b/claude-code-rust/src/app/events/tool_updates.rs new file mode 100644 index 0000000..97f5120 --- /dev/null +++ b/claude-code-rust/src/app/events/tool_updates.rs @@ -0,0 +1,487 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::super::{App, AppStatus, InvalidationLevel, MessageBlock, ToolCallInfo, ToolCallScope}; +use super::tool_calls::{ + has_in_progress_tool_calls, sdk_tool_name_from_meta, should_jump_on_large_write, +}; +use crate::agent::error_handling::{looks_like_internal_error, summarize_internal_error}; +use crate::agent::model; +use crate::app::todos::{parse_todos_if_present, set_todos}; +use std::time::Instant; + +pub(super) fn handle_tool_call_update_session(app: &mut App, tcu: &model::ToolCallUpdate) { + let id_str = tcu.tool_call_id.clone(); + log_tool_call_update_received(&id_str, tcu); + maybe_log_internal_failed_tool_update(&id_str, tcu); + let Some((mi, bi)) = app.lookup_tool_call(&id_str) else { + tracing::warn!("ToolCallUpdate: id={id_str} not found in index"); + return; + }; + let tool_scope = app.tool_call_scope(&id_str); + apply_tool_scope_status_update(app, &id_str, tool_scope, tcu.fields.status); + + let update_outcome = apply_tool_call_update_to_indexed_block(app, mi, bi, &id_str, tcu); + if let Some(mi) = update_outcome.layout_dirty_idx { + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } + if let Some(todos) = update_outcome.pending_todos { + set_todos(app, todos); + } + if matches!(app.status, AppStatus::Running) && !has_in_progress_tool_calls(app) { + app.status = AppStatus::Thinking; + } +} + +fn log_tool_call_update_received(id_str: &str, tcu: &model::ToolCallUpdate) { + let has_content = tcu.fields.content.as_ref().map_or(0, Vec::len); + let has_raw_output = tcu.fields.raw_output.is_some(); + tracing::debug!( + "ToolCallUpdate: id={id_str} new_title={:?} new_status={:?} content_blocks={has_content} has_raw_output={has_raw_output}", + tcu.fields.title, + tcu.fields.status + ); + if has_raw_output { + tracing::debug!("ToolCallUpdate raw_output: id={id_str} {:?}", tcu.fields.raw_output); + } +} + +fn maybe_log_internal_failed_tool_update(id_str: &str, tcu: &model::ToolCallUpdate) { + if matches!(tcu.fields.status, Some(model::ToolCallStatus::Failed)) + && let Some(content_preview) = internal_failed_tool_content_preview( + tcu.fields.content.as_deref(), + tcu.fields.raw_output.as_ref(), + ) + { + let sdk_tool_name = sdk_tool_name_from_meta(tcu.meta.as_ref()); + tracing::debug!( + tool_call_id = %id_str, + title = ?tcu.fields.title, + sdk_tool_name = ?sdk_tool_name, + content_preview = %content_preview, + "Internal failed ToolCallUpdate payload" + ); + } +} + +fn apply_tool_scope_status_update( + app: &mut App, + id_str: &str, + tool_scope: Option, + status: Option, +) { + let Some(status) = status else { + return; + }; + match tool_scope { + Some(ToolCallScope::Subagent) => match status { + model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress => { + app.mark_subagent_tool_started(id_str); + } + model::ToolCallStatus::Completed | model::ToolCallStatus::Failed => { + app.mark_subagent_tool_finished(id_str, Instant::now()); + } + }, + Some(ToolCallScope::Task) => match status { + model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress => { + app.refresh_subagent_idle_since(Instant::now()); + } + model::ToolCallStatus::Completed | model::ToolCallStatus::Failed => { + app.remove_active_task(id_str); + app.refresh_subagent_idle_since(Instant::now()); + } + }, + Some(ToolCallScope::MainAgent) | None => {} + } +} + +struct ToolCallUpdateApplyOutcome { + layout_dirty_idx: Option, + pending_todos: Option>, +} + +fn apply_tool_call_update_to_indexed_block( + app: &mut App, + mi: usize, + bi: usize, + id_str: &str, + tcu: &model::ToolCallUpdate, +) -> ToolCallUpdateApplyOutcome { + let mut out = ToolCallUpdateApplyOutcome { layout_dirty_idx: None, pending_todos: None }; + let terminals = std::rc::Rc::clone(&app.terminals); + let mut terminal_subscription: Option = None; + let mut detach_terminal = false; + + if let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + { + let tc = tc.as_mut(); + let mut changed = false; + changed |= apply_tool_call_status_update(tc, tcu.fields.status); + changed |= apply_tool_call_title_update(tc, tcu.fields.title.as_deref(), &app.cwd_raw); + changed |= apply_tool_call_content_update( + tc, + tcu.fields.content.as_deref(), + &terminals, + &mut terminal_subscription, + ); + changed |= apply_tool_call_raw_input_update(tc, tcu.fields.raw_input.as_ref()); + changed |= apply_tool_call_output_metadata_update(tc, tcu.fields.output_metadata.as_ref()); + changed |= apply_tool_call_raw_output_update(tc, tcu.fields.raw_output.as_ref()); + changed |= apply_tool_call_name_update(tc, tcu.meta.as_ref()); + out.pending_todos = + extract_todo_updates_from_tool_call_update(id_str, tc, tcu.fields.raw_input.as_ref()); + detach_terminal = detach_terminal_if_final(tc); + + if changed { + if should_jump_on_large_write(tc) { + app.viewport.engage_auto_scroll(); + } + tc.mark_tool_call_layout_dirty(); + app.sync_render_cache_slot(mi, bi); + out.layout_dirty_idx = Some(mi); + } else { + crate::perf::mark("tool_update_noop_skips"); + } + } + + if detach_terminal { + app.untrack_terminal_tool_call(mi, bi); + } else if let Some(terminal_id) = terminal_subscription { + app.sync_terminal_tool_call(terminal_id, mi, bi); + } + + out +} + +fn apply_tool_call_status_update( + tc: &mut ToolCallInfo, + status: Option, +) -> bool { + if let Some(status) = status + && tc.status != status + { + tc.status = status; + return true; + } + false +} + +fn apply_tool_call_title_update(tc: &mut ToolCallInfo, title: Option<&str>, cwd_raw: &str) -> bool { + let Some(title) = title else { + return false; + }; + let shortened = super::tool_calls::shorten_tool_title(title, cwd_raw); + if tc.title == shortened { + return false; + } + tc.title = shortened; + true +} + +fn apply_tool_call_content_update( + tc: &mut ToolCallInfo, + content: Option<&[model::ToolCallContent]>, + terminals: &crate::agent::events::TerminalMap, + terminal_subscription: &mut Option, +) -> bool { + let Some(content) = content else { + return false; + }; + let mut changed = false; + for cb in content { + if let model::ToolCallContent::Terminal(t) = cb { + let tid = t.terminal_id.clone(); + if let Some(terminal) = terminals.borrow().get(&tid) + && tc.terminal_command.as_deref() != Some(terminal.command.as_str()) + { + tc.terminal_command = Some(terminal.command.clone()); + changed = true; + } + if tc.terminal_id.as_deref() != Some(tid.as_str()) { + tc.terminal_id = Some(tid.clone()); + changed = true; + } + *terminal_subscription = Some(tid); + } + } + if tc.content != content { + tc.content = content.to_vec(); + changed = true; + } + changed +} + +fn apply_tool_call_raw_input_update( + tc: &mut ToolCallInfo, + raw_input: Option<&serde_json::Value>, +) -> bool { + let Some(raw_input) = raw_input else { + return false; + }; + tc.set_raw_input(Some(raw_input.clone())) +} + +fn apply_tool_call_output_metadata_update( + tc: &mut ToolCallInfo, + output_metadata: Option<&model::ToolOutputMetadata>, +) -> bool { + let Some(output_metadata) = output_metadata else { + return false; + }; + if tc.output_metadata.as_ref() == Some(output_metadata) { + return false; + } + tc.output_metadata = Some(output_metadata.clone()); + true +} + +fn apply_tool_call_raw_output_update( + tc: &mut ToolCallInfo, + raw_output: Option<&serde_json::Value>, +) -> bool { + if !tc.is_execute_tool() { + return false; + } + let Some(raw_output) = raw_output else { + return false; + }; + let Some(output) = raw_output_to_terminal_text(raw_output) else { + return false; + }; + if tc.terminal_output.as_deref() == Some(output.as_str()) { + return false; + } + tc.terminal_output_len = output.len(); + tc.terminal_bytes_seen = output.len(); + tc.terminal_output = Some(output); + tc.terminal_snapshot_mode = crate::app::TerminalSnapshotMode::ReplaceSnapshot; + true +} + +fn apply_tool_call_name_update(tc: &mut ToolCallInfo, meta: Option<&serde_json::Value>) -> bool { + let Some(name) = sdk_tool_name_from_meta(meta) else { + return false; + }; + if name.trim().is_empty() || tc.sdk_tool_name == name { + return false; + } + name.clone_into(&mut tc.sdk_tool_name); + true +} + +fn detach_terminal_if_final(tc: &mut ToolCallInfo) -> bool { + if !tc.is_execute_tool() + || matches!(tc.status, model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress) + || tc.terminal_id.is_none() + { + return false; + } + + tc.terminal_id = None; + true +} + +fn extract_todo_updates_from_tool_call_update( + id_str: &str, + tc: &ToolCallInfo, + raw_input: Option<&serde_json::Value>, +) -> Option> { + if tc.sdk_tool_name != "TodoWrite" { + return None; + } + tracing::info!("TodoWrite ToolCallUpdate: id={id_str}, raw_input={raw_input:?}"); + let raw_input = raw_input?; + if let Some(todos) = parse_todos_if_present(raw_input) { + tracing::info!("Parsed {} todos from ToolCallUpdate raw_input", todos.len()); + return Some(todos); + } + tracing::debug!( + "TodoWrite ToolCallUpdate raw_input has no todos array yet; preserving existing todos" + ); + None +} + +pub(super) fn raw_output_to_terminal_text(raw_output: &serde_json::Value) -> Option { + match raw_output { + serde_json::Value::Null => None, + serde_json::Value::String(s) => (!s.is_empty()).then(|| s.clone()), + serde_json::Value::Array(items) => { + let chunks: Vec<&str> = items.iter().filter_map(extract_text_field).collect(); + if chunks.is_empty() { + serde_json::to_string_pretty(raw_output).ok().filter(|s| !s.is_empty()) + } else { + Some(chunks.join("\n")) + } + } + value => extract_text_field(value) + .map(str::to_owned) + .or_else(|| serde_json::to_string_pretty(value).ok().filter(|s| !s.is_empty())), + } +} + +fn extract_text_field(value: &serde_json::Value) -> Option<&str> { + value.get("text").and_then(serde_json::Value::as_str) +} + +fn internal_failed_tool_content_preview( + content: Option<&[model::ToolCallContent]>, + raw_output: Option<&serde_json::Value>, +) -> Option { + let text = content + .and_then(|items| { + items.iter().find_map(|c| match c { + model::ToolCallContent::Content(inner) => match &inner.content { + model::ContentBlock::Text(t) => Some(t.text.clone()), + model::ContentBlock::Image(_) => None, + }, + _ => None, + }) + }) + .or_else(|| raw_output.and_then(raw_output_to_terminal_text))?; + if !looks_like_internal_error(&text) { + return None; + } + Some(summarize_internal_error(&text)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ + App, BlockCache, ChatMessage, MessageBlock, MessageRole, TerminalSnapshotMode, + }; + + fn make_bash_tool_call( + id: &str, + status: model::ToolCallStatus, + terminal_id: Option<&str>, + ) -> ToolCallInfo { + ToolCallInfo { + id: id.to_owned(), + title: format!("tool {id}"), + sdk_tool_name: "Bash".to_owned(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status, + content: Vec::new(), + hidden: false, + terminal_id: terminal_id.map(str::to_owned), + terminal_command: Some("echo test".to_owned()), + terminal_output: None, + terminal_output_len: 0, + terminal_bytes_seen: 0, + terminal_snapshot_mode: TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + } + } + + fn terminal_content(terminal_id: &str) -> Vec { + vec![model::ToolCallContent::Terminal(model::TerminalToolCallContent::new(terminal_id))] + } + + #[test] + fn completed_execute_update_detaches_terminal_subscription() { + let mut app = App::test_default(); + let tool_id = "tool-1"; + app.messages.push(ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(make_bash_tool_call( + tool_id, + model::ToolCallStatus::InProgress, + Some("term-1"), + )))], + usage: None, + }); + app.index_tool_call(tool_id.to_owned(), 0, 0); + app.sync_terminal_tool_call("term-1".to_owned(), 0, 0); + + let update = model::ToolCallUpdate::new( + tool_id, + model::ToolCallUpdateFields::new() + .status(model::ToolCallStatus::Completed) + .raw_output(serde_json::Value::String("done".to_owned())), + ); + + handle_tool_call_update_session(&mut app, &update); + + let MessageBlock::ToolCall(tc) = &app.messages[0].blocks[0] else { + panic!("expected tool call block"); + }; + assert_eq!(tc.status, model::ToolCallStatus::Completed); + assert_eq!(tc.terminal_id, None); + assert_eq!(tc.terminal_output.as_deref(), Some("done")); + assert!(app.terminal_tool_calls.is_empty()); + assert!(app.terminal_tool_call_membership.is_empty()); + } + + #[test] + fn repeated_terminal_updates_do_not_duplicate_subscription() { + let mut app = App::test_default(); + let tool_id = "tool-1"; + app.messages.push(ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(make_bash_tool_call( + tool_id, + model::ToolCallStatus::InProgress, + None, + )))], + usage: None, + }); + app.index_tool_call(tool_id.to_owned(), 0, 0); + + let update = model::ToolCallUpdate::new( + tool_id, + model::ToolCallUpdateFields::new().content(terminal_content("term-1")), + ); + + handle_tool_call_update_session(&mut app, &update); + handle_tool_call_update_session(&mut app, &update); + + assert_eq!(app.terminal_tool_calls.len(), 1); + assert_eq!(app.terminal_tool_call_membership.len(), 1); + assert_eq!(app.terminal_tool_calls[0].terminal_id, "term-1"); + } + + #[test] + fn terminal_update_replaces_stale_subscription_for_same_tool_call() { + let mut app = App::test_default(); + let tool_id = "tool-1"; + app.messages.push(ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(make_bash_tool_call( + tool_id, + model::ToolCallStatus::InProgress, + Some("term-1"), + )))], + usage: None, + }); + app.index_tool_call(tool_id.to_owned(), 0, 0); + app.sync_terminal_tool_call("term-1".to_owned(), 0, 0); + + let update = model::ToolCallUpdate::new( + tool_id, + model::ToolCallUpdateFields::new().content(terminal_content("term-2")), + ); + + handle_tool_call_update_session(&mut app, &update); + + assert_eq!(app.terminal_tool_calls.len(), 1); + assert_eq!(app.terminal_tool_call_membership.len(), 1); + assert_eq!(app.terminal_tool_calls[0].terminal_id, "term-2"); + let MessageBlock::ToolCall(tc) = &app.messages[0].blocks[0] else { + panic!("expected tool call block"); + }; + assert_eq!(tc.terminal_id.as_deref(), Some("term-2")); + } +} diff --git a/claude-code-rust/src/app/events/turn.rs b/claude-code-rust/src/app/events/turn.rs new file mode 100644 index 0000000..604977a --- /dev/null +++ b/claude-code-rust/src/app/events/turn.rs @@ -0,0 +1,418 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::super::{ + App, AppStatus, CancelOrigin, ChatMessage, FocusTarget, InlinePermission, InlineQuestion, + InvalidationLevel, MessageBlock, MessageRole, SystemSeverity, TextBlock, +}; +use super::clear_compaction_state; +use super::rate_limit::format_rate_limit_summary; +use crate::agent::error_handling::{TurnErrorClass, classify_turn_error, summarize_internal_error}; +use crate::agent::model; +use std::collections::BTreeSet; + +const CONVERSATION_INTERRUPTED_HINT: &str = + "Conversation interrupted. Tell the model how to proceed."; +const TURN_ERROR_INPUT_LOCK_HINT: &str = + "Input disabled after an error. Press Ctrl+Q to quit and try again."; +const PLAN_LIMIT_NEXT_STEPS_HINT: &str = "Next steps:\n\ +1. Wait a few minutes and retry.\n\ +2. Reduce request size or request frequency.\n\ +3. Check quota/billing for your account or switch plans."; +const AUTH_REQUIRED_NEXT_STEPS_HINT: &str = "Authentication required. Type /login to authenticate, or run `claude auth login` in a terminal."; + +#[derive(Clone, Copy)] +struct TurnExitState { + tail_assistant_idx: Option, + turn_was_active: bool, + cancelled_requested: Option, + show_interrupted_hint: bool, +} + +pub(super) fn handle_permission_request_event( + app: &mut App, + request: model::RequestPermissionRequest, + response_tx: tokio::sync::oneshot::Sender, +) { + let tool_id = request.tool_call.tool_call_id.clone(); + let options = request.options.clone(); + + let Some((mi, bi)) = app.lookup_tool_call(&tool_id) else { + tracing::warn!("Permission request for unknown tool call: {tool_id}; auto-rejecting"); + reject_permission_request(response_tx, &options); + return; + }; + + if app.pending_interaction_ids.iter().any(|id| id == &tool_id) { + tracing::warn!( + "Duplicate permission request for tool call: {tool_id}; auto-rejecting duplicate" + ); + reject_permission_request(response_tx, &options); + return; + } + + let mut layout_dirty = false; + if let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + { + let tc = tc.as_mut(); + let is_first = app.pending_interaction_ids.is_empty(); + tc.pending_permission = Some(InlinePermission { + options: request.options, + response_tx, + selected_index: 0, + focused: is_first, + }); + tc.mark_tool_call_layout_dirty(); + layout_dirty = true; + app.pending_interaction_ids.push(tool_id); + app.claim_focus_target(FocusTarget::Permission); + app.viewport.engage_auto_scroll(); + app.notifications.notify( + app.config.preferred_notification_channel_effective(), + super::super::notify::NotifyEvent::PermissionRequired, + ); + } else { + tracing::warn!("Permission request for non-tool block index: {tool_id}; auto-rejecting"); + reject_permission_request(response_tx, &options); + } + + if layout_dirty { + app.sync_render_cache_slot(mi, bi); + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } +} + +pub(super) fn handle_question_request_event( + app: &mut App, + request: model::RequestQuestionRequest, + response_tx: tokio::sync::oneshot::Sender, +) { + let tool_id = request.tool_call.tool_call_id.clone(); + + let Some((mi, bi)) = app.lookup_tool_call(&tool_id) else { + tracing::warn!("Question request for unknown tool call: {tool_id}; auto-cancelling"); + let _ = response_tx + .send(model::RequestQuestionResponse::new(model::RequestQuestionOutcome::Cancelled)); + return; + }; + + if app.pending_interaction_ids.iter().any(|id| id == &tool_id) { + tracing::warn!( + "Duplicate inline interaction request for tool call: {tool_id}; auto-cancelling duplicate" + ); + let _ = response_tx + .send(model::RequestQuestionResponse::new(model::RequestQuestionOutcome::Cancelled)); + return; + } + + let mut layout_dirty = false; + if let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + { + let tc = tc.as_mut(); + let is_first = app.pending_interaction_ids.is_empty(); + tc.pending_question = Some(InlineQuestion { + prompt: request.prompt, + response_tx, + focused_option_index: 0, + selected_option_indices: BTreeSet::new(), + notes: String::new(), + notes_cursor: 0, + editing_notes: false, + focused: is_first, + question_index: request.question_index, + total_questions: request.total_questions, + }); + tc.mark_tool_call_layout_dirty(); + layout_dirty = true; + app.pending_interaction_ids.push(tool_id); + app.claim_focus_target(FocusTarget::Permission); + app.viewport.engage_auto_scroll(); + app.notifications.notify( + app.config.preferred_notification_channel_effective(), + super::super::notify::NotifyEvent::QuestionRequired, + ); + } else { + tracing::warn!("Question request for non-tool block index: {tool_id}; auto-cancelling"); + let _ = response_tx + .send(model::RequestQuestionResponse::new(model::RequestQuestionOutcome::Cancelled)); + } + + if layout_dirty { + app.sync_render_cache_slot(mi, bi); + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } +} + +fn reject_permission_request( + response_tx: tokio::sync::oneshot::Sender, + options: &[model::PermissionOption], +) { + if let Some(last_opt) = options.last() { + let _ = response_tx.send(model::RequestPermissionResponse::new( + model::RequestPermissionOutcome::Selected(model::SelectedPermissionOutcome::new( + last_opt.option_id.clone(), + )), + )); + } +} + +pub(super) fn handle_turn_cancelled_event(app: &mut App) { + if app.pending_cancel_origin.is_none() { + app.pending_cancel_origin = Some(CancelOrigin::Manual); + } + app.cancelled_turn_pending_hint = + matches!(app.pending_cancel_origin, Some(CancelOrigin::Manual)); + let _ = app.finalize_in_progress_tool_calls(model::ToolCallStatus::Failed); +} + +fn begin_turn_exit(app: &mut App, emit_manual_compaction_success: bool) -> TurnExitState { + let state = TurnExitState { + tail_assistant_idx: app + .messages + .iter() + .rposition(|m| matches!(m.role, MessageRole::Assistant)), + turn_was_active: matches!(app.status, AppStatus::Thinking | AppStatus::Running), + cancelled_requested: app.pending_cancel_origin, + show_interrupted_hint: matches!(app.pending_cancel_origin, Some(CancelOrigin::Manual)), + }; + clear_compaction_state(app, emit_manual_compaction_success); + app.pending_cancel_origin = None; + app.cancelled_turn_pending_hint = false; + state +} + +fn finish_ready_turn_exit(app: &mut App, exit: TurnExitState, tool_status: model::ToolCallStatus) { + app.finalize_turn_runtime_artifacts(tool_status); + app.status = AppStatus::Ready; + app.files_accessed = 0; + app.refresh_git_branch(); + + let removed_tail_assistant = remove_empty_tail_assistant(app, exit.tail_assistant_idx); + if exit.show_interrupted_hint { + push_interrupted_hint(app); + } + if removed_tail_assistant.is_none() + && (exit.turn_was_active || exit.cancelled_requested.is_some()) + { + mark_turn_exit_assistant_layout_dirty(app, exit.tail_assistant_idx); + } + app.clear_active_turn_assistant(); +} + +pub(super) fn handle_turn_complete_event(app: &mut App) { + let exit = begin_turn_exit(app, true); + let turn_was_active = exit.turn_was_active; + let tool_status = if exit.cancelled_requested.is_some() { + model::ToolCallStatus::Failed + } else { + model::ToolCallStatus::Completed + }; + finish_ready_turn_exit(app, exit, tool_status); + if turn_was_active { + app.notifications.notify( + app.config.preferred_notification_channel_effective(), + super::super::notify::NotifyEvent::TurnComplete, + ); + } + if app.active_view == super::super::ActiveView::Chat { + super::super::input_submit::maybe_auto_submit_after_cancel(app); + } +} + +pub(super) fn handle_turn_error_event( + app: &mut App, + msg: &str, + classified: Option, +) { + let exit = begin_turn_exit(app, true); + + if exit.cancelled_requested.is_some() { + let summary = summarize_internal_error(msg); + tracing::warn!( + error_preview = %summary, + "Turn error suppressed after cancellation request" + ); + app.pending_submit = None; + finish_ready_turn_exit(app, exit, model::ToolCallStatus::Failed); + if app.active_view == super::super::ActiveView::Chat { + super::super::input_submit::maybe_auto_submit_after_cancel(app); + } + return; + } + + let error_class = classified.unwrap_or_else(|| classify_turn_error(msg)); + tracing::error!("Turn error: {msg}"); + let summary = summarize_internal_error(msg); + match error_class { + TurnErrorClass::PlanLimit => { + tracing::warn!( + error_preview = %summary, + "Turn error classified as plan/usage limit" + ); + } + TurnErrorClass::AuthRequired => { + tracing::warn!( + error_preview = %summary, + "Turn error indicates authentication is required" + ); + app.exit_error = Some(crate::error::AppError::AuthRequired); + app.should_quit = true; + } + TurnErrorClass::Internal => { + tracing::debug!( + error_preview = %summary, + "Internal Agent SDK turn error payload" + ); + } + TurnErrorClass::Other => {} + } + app.finalize_turn_runtime_artifacts(model::ToolCallStatus::Failed); + app.pending_auto_submit_after_cancel = false; + app.input.clear(); + app.pending_submit = None; + app.status = AppStatus::Error; + let rate_limit_context = if matches!(error_class, TurnErrorClass::PlanLimit) { + app.last_rate_limit_update + .clone() + .filter(|update| !matches!(update.status, model::RateLimitStatus::Allowed)) + } else { + None + }; + let removed_tail_assistant = remove_empty_tail_assistant(app, exit.tail_assistant_idx); + push_turn_error_message(app, msg, error_class, rate_limit_context.as_ref()); + if removed_tail_assistant.is_none() && exit.turn_was_active { + mark_turn_exit_assistant_layout_dirty(app, exit.tail_assistant_idx); + } + app.clear_active_turn_assistant(); +} + +fn push_interrupted_hint(app: &mut App) { + app.push_message_tracked(ChatMessage { + role: MessageRole::System(Some(SystemSeverity::Info)), + blocks: vec![MessageBlock::Text(TextBlock::from_complete(CONVERSATION_INTERRUPTED_HINT))], + usage: None, + }); + app.enforce_history_retention_tracked(); + app.viewport.engage_auto_scroll(); +} + +fn remove_empty_tail_assistant(app: &mut App, idx: Option) -> Option { + let idx = idx?; + let should_remove = app + .messages + .get(idx) + .is_some_and(|msg| matches!(msg.role, MessageRole::Assistant) && msg.blocks.is_empty()); + if !should_remove { + return None; + } + app.remove_message_tracked(idx)?; + Some(idx) +} + +fn mark_turn_exit_assistant_layout_dirty(app: &mut App, idx: Option) { + let Some(idx) = idx else { + return; + }; + if app.messages.get(idx).is_some_and(|msg| matches!(msg.role, MessageRole::Assistant)) { + app.invalidate_layout(InvalidationLevel::MessageChanged(idx)); + } +} + +fn push_turn_error_message( + app: &mut App, + error: &str, + class: TurnErrorClass, + rate_limit_context: Option<&model::RateLimitUpdate>, +) { + let base_message = match class { + TurnErrorClass::PlanLimit => { + let summary = summarize_internal_error(error); + format!( + "Turn blocked by account or plan limits: {summary}\n\n{PLAN_LIMIT_NEXT_STEPS_HINT}\n\n{TURN_ERROR_INPUT_LOCK_HINT}" + ) + } + TurnErrorClass::AuthRequired => { + format!("{AUTH_REQUIRED_NEXT_STEPS_HINT}\n\n{TURN_ERROR_INPUT_LOCK_HINT}") + } + TurnErrorClass::Internal | TurnErrorClass::Other => { + format!("Turn failed: {error}\n\n{TURN_ERROR_INPUT_LOCK_HINT}") + } + }; + let (severity, message) = if matches!(class, TurnErrorClass::PlanLimit) + && let Some(update) = rate_limit_context + { + let prefix = format_rate_limit_summary(update); + let severity = match update.status { + model::RateLimitStatus::AllowedWarning => Some(SystemSeverity::Warning), + model::RateLimitStatus::Rejected | model::RateLimitStatus::Allowed => None, + }; + (severity, format!("{prefix}\n\n{base_message}")) + } else { + (None, base_message) + }; + super::push_system_message_with_severity(app, severity, &message); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + + fn empty_assistant_message() -> ChatMessage { + ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None } + } + + fn user_message(text: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::User, + blocks: vec![MessageBlock::Text(TextBlock::from_complete(text))], + usage: None, + } + } + + #[test] + fn turn_complete_removes_empty_tail_assistant() { + let mut app = App::test_default(); + app.status = AppStatus::Thinking; + app.messages.push(user_message("hello")); + app.messages.push(empty_assistant_message()); + + handle_turn_complete_event(&mut app); + + assert_eq!(app.messages.len(), 1); + assert!(matches!(app.messages[0].role, MessageRole::User)); + } + + #[test] + fn cancelled_turn_error_removes_empty_tail_assistant_before_hint() { + let mut app = App::test_default(); + app.status = AppStatus::Thinking; + app.pending_cancel_origin = Some(CancelOrigin::Manual); + app.messages.push(user_message("hello")); + app.messages.push(empty_assistant_message()); + + handle_turn_error_event(&mut app, "cancelled", None); + + assert_eq!(app.messages.len(), 2); + assert!(matches!(app.messages[0].role, MessageRole::User)); + assert!(matches!(app.messages[1].role, MessageRole::System(Some(SystemSeverity::Info)))); + } + + #[test] + fn turn_error_removes_empty_tail_assistant_before_error_message() { + let mut app = App::test_default(); + app.status = AppStatus::Thinking; + app.messages.push(user_message("hello")); + app.messages.push(empty_assistant_message()); + + handle_turn_error_event(&mut app, "boom", None); + + assert_eq!(app.messages.len(), 2); + assert!(matches!(app.messages[0].role, MessageRole::User)); + assert!(matches!(app.messages[1].role, MessageRole::System(None))); + } +} diff --git a/claude-code-rust/src/app/focus.rs b/claude-code-rust/src/app/focus.rs new file mode 100644 index 0000000..5c9337b --- /dev/null +++ b/claude-code-rust/src/app/focus.rs @@ -0,0 +1,149 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +/// Logical focus target that can claim directional key navigation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusTarget { + TodoList, + Mention, + Permission, + Help, +} + +/// Effective owner of directional/navigation keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusOwner { + Input, + TodoList, + Mention, + Permission, + Help, +} + +#[derive(Debug, Clone, Copy)] +#[allow(clippy::struct_excessive_bools)] +pub struct FocusContext { + pub todo_focus_available: bool, + pub mention_active: bool, + pub permission_active: bool, + pub help_active: bool, +} + +impl FocusContext { + #[must_use] + pub const fn new( + todo_focus_available: bool, + mention_active: bool, + permission_active: bool, + ) -> Self { + Self { todo_focus_available, mention_active, permission_active, help_active: false } + } + + #[must_use] + pub const fn with_help(mut self, help_active: bool) -> Self { + self.help_active = help_active; + self + } + + #[must_use] + pub const fn supports(self, target: FocusTarget) -> bool { + match target { + FocusTarget::TodoList => self.todo_focus_available, + FocusTarget::Mention => self.mention_active, + FocusTarget::Permission => self.permission_active, + FocusTarget::Help => self.help_active, + } + } +} + +impl From for FocusOwner { + fn from(value: FocusTarget) -> Self { + match value { + FocusTarget::TodoList => Self::TodoList, + FocusTarget::Mention => Self::Mention, + FocusTarget::Permission => Self::Permission, + FocusTarget::Help => Self::Help, + } + } +} + +/// Focus claim manager: +/// latest valid claim wins; invalid claims are dropped during normalization. +#[derive(Debug, Clone, Default)] +pub struct FocusManager { + stack: Vec, +} + +impl FocusManager { + /// Resolve the current focus owner for key routing. + #[must_use] + pub fn owner(&self, context: FocusContext) -> FocusOwner { + for target in self.stack.iter().rev().copied() { + if context.supports(target) { + return target.into(); + } + } + FocusOwner::Input + } + + /// Claim focus for the target. Latest valid claim wins. + pub fn claim(&mut self, target: FocusTarget, context: FocusContext) { + self.stack.retain(|t| *t != target); + self.stack.push(target); + self.normalize(context); + } + + /// Release focus claim for the target. + pub fn release(&mut self, target: FocusTarget, context: FocusContext) { + if let Some(idx) = self.stack.iter().rposition(|t| *t == target) { + self.stack.remove(idx); + } + self.normalize(context); + } + + /// Remove claims no longer valid in the current context. + pub fn normalize(&mut self, context: FocusContext) { + self.stack.retain(|target| context.supports(*target)); + } +} + +#[cfg(test)] +mod tests { + use super::{FocusContext, FocusManager, FocusOwner, FocusTarget}; + + #[test] + fn owner_defaults_to_input_without_claims() { + let mgr = FocusManager::default(); + let ctx = FocusContext::new(false, false, false); + assert_eq!(mgr.owner(ctx), FocusOwner::Input); + } + + #[test] + fn latest_valid_claim_wins() { + let mut mgr = FocusManager::default(); + let ctx = FocusContext::new(true, true, true); + mgr.claim(FocusTarget::TodoList, ctx); + mgr.claim(FocusTarget::Permission, ctx); + mgr.claim(FocusTarget::Mention, ctx); + assert_eq!(mgr.owner(ctx), FocusOwner::Mention); + } + + #[test] + fn invalid_claims_are_normalized_out() { + let mut mgr = FocusManager::default(); + let valid_ctx = FocusContext::new(true, false, false); + let invalid_ctx = FocusContext::new(false, false, false); + mgr.claim(FocusTarget::TodoList, valid_ctx); + assert_eq!(mgr.owner(valid_ctx), FocusOwner::TodoList); + mgr.normalize(invalid_ctx); + assert_eq!(mgr.owner(invalid_ctx), FocusOwner::Input); + } + + #[test] + fn help_focus_target_works_when_enabled() { + let mut mgr = FocusManager::default(); + let ctx = FocusContext::new(false, false, false).with_help(true); + mgr.claim(FocusTarget::Help, ctx); + assert_eq!(mgr.owner(ctx), FocusOwner::Help); + } +} diff --git a/claude-code-rust/src/app/inline_interactions.rs b/claude-code-rust/src/app/inline_interactions.rs new file mode 100644 index 0000000..d4a446d --- /dev/null +++ b/claude-code-rust/src/app/inline_interactions.rs @@ -0,0 +1,198 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::{ + App, FocusTarget, InvalidationLevel, MessageBlock, ToolCallInfo, permissions, questions, +}; +use crossterm::event::{KeyCode, KeyEvent}; + +pub(super) fn focused_interaction_id(app: &App) -> Option<&str> { + app.pending_interaction_ids.first().map(String::as_str) +} + +fn interaction_id_is_valid(app: &App, tool_id: &str) -> bool { + let Some((mi, bi)) = app.lookup_tool_call(tool_id) else { + return false; + }; + matches!( + app.messages.get(mi).and_then(|msg| msg.blocks.get(bi)), + Some(MessageBlock::ToolCall(tc)) + if tc.pending_permission.is_some() || tc.pending_question.is_some() + ) +} + +pub(super) fn focused_interaction(app: &App) -> Option<&ToolCallInfo> { + let tool_id = focused_interaction_id(app)?; + let (mi, bi) = app.tool_call_index.get(tool_id).copied()?; + let MessageBlock::ToolCall(tc) = app.messages.get(mi)?.blocks.get(bi)? else { + return None; + }; + Some(tc.as_ref()) +} + +pub(super) fn get_focused_interaction_tc(app: &mut App) -> Option<&mut ToolCallInfo> { + let tool_id = focused_interaction_id(app)?; + let (mi, bi) = app.tool_call_index.get(tool_id).copied()?; + match app.messages.get_mut(mi)?.blocks.get_mut(bi)? { + MessageBlock::ToolCall(tc) + if tc.pending_permission.is_some() || tc.pending_question.is_some() => + { + Some(tc.as_mut()) + } + _ => None, + } +} + +pub(super) fn focused_interaction_dirty_idx(app: &App) -> Option<(usize, usize)> { + focused_interaction_id(app).and_then(|tool_id| app.lookup_tool_call(tool_id)) +} + +pub(super) fn invalidate_if_changed( + app: &mut App, + dirty_idx: Option<(usize, usize)>, + changed: bool, +) { + if changed && let Some((mi, bi)) = dirty_idx { + app.sync_render_cache_slot(mi, bi); + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } +} + +pub(super) fn set_interaction_focused(app: &mut App, queue_index: usize, focused: bool) { + let Some(tool_id) = app.pending_interaction_ids.get(queue_index) else { + return; + }; + let Some((mi, bi)) = app.tool_call_index.get(tool_id).copied() else { + return; + }; + let mut invalidated = false; + if let Some(msg) = app.messages.get_mut(mi) + && let Some(MessageBlock::ToolCall(tc)) = msg.blocks.get_mut(bi) + { + let tc = tc.as_mut(); + if let Some(ref mut perm) = tc.pending_permission + && perm.focused != focused + { + perm.focused = focused; + tc.mark_tool_call_layout_dirty(); + invalidated = true; + } + if let Some(ref mut question) = tc.pending_question + && question.focused != focused + { + question.focused = focused; + tc.mark_tool_call_layout_dirty(); + invalidated = true; + } + } + if invalidated { + app.sync_render_cache_slot(mi, bi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } +} + +pub(super) fn focused_interaction_is_active(app: &App) -> bool { + focused_interaction(app).is_some_and(|tc| { + tc.pending_permission.as_ref().is_some_and(|permission| permission.focused) + || tc.pending_question.as_ref().is_some_and(|question| question.focused) + }) +} + +pub(super) fn focus_next_inline_interaction(app: &mut App) { + normalize_pending_interaction_queue(app); + set_interaction_focused(app, 0, true); + if app.pending_interaction_ids.is_empty() { + app.release_focus_target(FocusTarget::Permission); + } else { + app.claim_focus_target(FocusTarget::Permission); + } +} + +pub(super) fn normalize_pending_interaction_queue(app: &mut App) { + let previous = std::mem::take(&mut app.pending_interaction_ids); + let previous_order = previous.clone(); + let mut queue = Vec::with_capacity(previous.len()); + for id in previous { + if interaction_id_is_valid(app, &id) { + queue.push(id); + } + } + let changed = queue != previous_order; + app.pending_interaction_ids = queue; + + if app.pending_interaction_ids.is_empty() { + app.release_focus_target(FocusTarget::Permission); + app.normalize_focus_stack(); + return; + } + + if changed { + for idx in 0..app.pending_interaction_ids.len() { + set_interaction_focused(app, idx, idx == 0); + } + } + app.claim_focus_target(FocusTarget::Permission); + app.normalize_focus_stack(); +} + +pub(super) fn pop_next_valid_interaction_id(app: &mut App) -> Option { + normalize_pending_interaction_queue(app); + (!app.pending_interaction_ids.is_empty()).then(|| app.pending_interaction_ids.remove(0)) +} + +pub(super) fn handle_interaction_focus_cycle( + app: &mut App, + key: KeyEvent, + interaction_has_focus: bool, + blocks_vertical_navigation: bool, +) -> Option { + if !interaction_has_focus { + return None; + } + if !matches!(key.code, KeyCode::Up | KeyCode::Down) { + return None; + } + if app.pending_interaction_ids.len() <= 1 { + if blocks_vertical_navigation { + return None; + } + return Some(true); + } + + set_interaction_focused(app, 0, false); + + if key.code == KeyCode::Down { + let first = app.pending_interaction_ids.remove(0); + app.pending_interaction_ids.push(first); + } else { + let Some(last) = app.pending_interaction_ids.pop() else { + return Some(false); + }; + app.pending_interaction_ids.insert(0, last); + } + + set_interaction_focused(app, 0, true); + app.viewport.engage_auto_scroll(); + Some(true) +} + +pub(super) fn handle_inline_interaction_key(app: &mut App, key: KeyEvent) -> bool { + normalize_pending_interaction_queue(app); + let interaction_has_focus = focused_interaction_is_active(app); + let has_question = questions::has_focused_question(app); + let plan_approval = permissions::focused_permission_is_plan_approval(app); + + if let Some(consumed) = handle_interaction_focus_cycle( + app, + key, + interaction_has_focus, + has_question || plan_approval, + ) { + return consumed; + } + if has_question { + return questions::handle_question_key(app, key, interaction_has_focus).unwrap_or(false); + } + permissions::handle_permission_key(app, key, interaction_has_focus) +} diff --git a/claude-code-rust/src/app/input.rs b/claude-code-rust/src/app/input.rs new file mode 100644 index 0000000..9cd748a --- /dev/null +++ b/claude-code-rust/src/app/input.rs @@ -0,0 +1,1616 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use tui_textarea::{CursorMove, TextArea, WrapMode}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputSnapshot { + pub lines: Vec, + pub cursor_row: usize, + pub cursor_col: usize, + pub paste_blocks: Vec, +} + +#[derive(Debug)] +pub struct InputState { + /// Monotonically increasing version counter. Bumped on every content or cursor change + /// so that downstream caches (e.g. wrap result) can detect staleness cheaply. + pub version: u64, + /// Bumped only when visible input content changes (not cursor-only movement). + /// Used to key wrap/highlight caches that don't depend on cursor position. + pub content_version: u64, + /// Stored paste blocks: each entry holds the full text of a large paste (>1000 chars). + /// A placeholder token `[Pasted Text N]` is inserted into `lines` at the paste point. + /// On `text()`, placeholders are expanded back to the original pasted content. + pub paste_blocks: Vec, + /// Cached visual line measurement: (`content_version`, width, `max_rows`, result). + cached_measure: Option<(u64, u16, u16, u16)>, + /// Tracks which `content_version` highlights were last applied for. + /// Initialized to `u64::MAX` so the first render always applies highlights. + pub highlight_version: u64, + editor: TextArea<'static>, +} + +/// Prefix/suffix used to identify paste placeholder lines in the input buffer. +const PASTE_PREFIX: &str = "[Pasted Text "; +const PASTE_SUFFIX: &str = "]"; +/// Character threshold above which pasted content is collapsed to a placeholder. +pub const PASTE_PLACEHOLDER_CHAR_THRESHOLD: usize = 1000; + +impl InputState { + fn configure_editor(editor: &mut TextArea<'static>) { + editor.set_wrap_mode(WrapMode::WordOrGlyph); + } + + fn bump_cursor_version(&mut self) { + self.version += 1; + } + + fn bump_content_version(&mut self) { + self.version += 1; + self.content_version += 1; + } + + pub fn new() -> Self { + let mut editor = TextArea::default(); + Self::configure_editor(&mut editor); + Self { + version: 0, + content_version: 0, + paste_blocks: Vec::new(), + cached_measure: None, + highlight_version: u64::MAX, + editor, + } + } + + #[must_use] + pub fn editor(&self) -> &TextArea<'static> { + &self.editor + } + + pub fn editor_mut(&mut self) -> &mut TextArea<'static> { + &mut self.editor + } + + #[must_use] + pub fn lines(&self) -> &[String] { + self.editor.lines() + } + + #[must_use] + pub fn cursor(&self) -> (usize, usize) { + self.editor.cursor() + } + + #[must_use] + pub fn cursor_row(&self) -> usize { + self.cursor().0 + } + + #[must_use] + pub fn cursor_col(&self) -> usize { + self.cursor().1 + } + + pub fn set_cursor(&mut self, row: usize, col: usize) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::Jump( + u16::try_from(row).unwrap_or(u16::MAX), + u16::try_from(col).unwrap_or(u16::MAX), + )); + }) + } + + pub fn set_cursor_col(&mut self, col: usize) -> bool { + self.set_cursor(self.cursor_row(), col) + } + + pub fn replace_lines_and_cursor( + &mut self, + mut lines: Vec, + cursor_row: usize, + cursor_col: usize, + ) { + if lines.is_empty() { + lines.push(String::new()); + } + self.editor.set_lines(lines, (cursor_row, cursor_col)); + self.bump_content_version(); + } + + pub fn clear_custom_highlights(&mut self) { + self.editor.clear_custom_highlight(); + } + + pub fn mutate_lines(&mut self, edit: impl FnOnce(&mut Vec)) { + let mut lines = self.lines().to_vec(); + edit(&mut lines); + let (row, col) = self.cursor(); + self.replace_lines_and_cursor(lines, row, col); + } + + #[must_use] + pub fn text(&self) -> String { + if self.paste_blocks.is_empty() { + return self.lines().join("\n"); + } + // Expand paste placeholders back to their full content. + let mut result = String::new(); + for (i, line) in self.lines().iter().enumerate() { + if i > 0 { + result.push('\n'); + } + result.push_str(&expand_placeholders_in_line(line, &self.paste_blocks)); + } + result + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.lines().len() == 1 && self.lines()[0].is_empty() + } + + pub fn clear(&mut self) { + self.paste_blocks.clear(); + self.replace_lines_and_cursor(vec![String::new()], 0, 0); + } + + #[must_use] + pub fn snapshot(&self) -> InputSnapshot { + let (cursor_row, cursor_col) = self.cursor(); + InputSnapshot { + lines: self.lines().to_vec(), + cursor_row, + cursor_col, + paste_blocks: self.paste_blocks.clone(), + } + } + + pub fn restore_snapshot(&mut self, snapshot: InputSnapshot) { + self.paste_blocks = snapshot.paste_blocks; + self.replace_lines_and_cursor(snapshot.lines, snapshot.cursor_row, snapshot.cursor_col); + } + + /// Replace the input with the given text, placing the cursor at the end. + pub fn set_text(&mut self, text: &str) { + let mut lines: Vec = text.split('\n').map(String::from).collect(); + if lines.is_empty() { + lines.push(String::new()); + } + let row = lines.len().saturating_sub(1); + let col = lines[row].chars().count(); + self.paste_blocks.clear(); + self.replace_lines_and_cursor(lines, row, col); + } + + pub fn insert_char(&mut self, c: char) { + let _ = self.textarea_insert_char(c); + } + + fn apply_cursor_edit(&mut self, edit: impl FnOnce(&mut TextArea<'_>)) -> bool { + let before = self.editor.cursor(); + edit(&mut self.editor); + let changed = before != self.editor.cursor(); + if changed { + self.bump_cursor_version(); + } + changed + } + + pub fn textarea_insert_char(&mut self, c: char) -> bool { + self.editor.insert_char(c); + self.bump_content_version(); + true + } + + pub fn textarea_insert_newline(&mut self) -> bool { + self.editor.insert_newline(); + self.bump_content_version(); + true + } + + pub fn textarea_delete_char_before(&mut self) -> bool { + let changed = self.editor.delete_char(); + if changed { + self.bump_content_version(); + } + changed + } + + pub fn textarea_delete_char_after(&mut self) -> bool { + let changed = self.editor.delete_next_char(); + if changed { + self.bump_content_version(); + } + changed + } + + pub fn textarea_move_left(&mut self) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::Back); + }) + } + + pub fn textarea_move_right(&mut self) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::Forward); + }) + } + + pub fn textarea_move_up(&mut self) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::Up); + }) + } + + pub fn textarea_move_down(&mut self) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::Down); + }) + } + + pub fn textarea_move_home(&mut self) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::Head); + }) + } + + pub fn textarea_move_end(&mut self) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::End); + }) + } + + pub fn textarea_undo(&mut self) -> bool { + let changed = self.editor.undo(); + if changed { + self.bump_content_version(); + } + changed + } + + pub fn textarea_redo(&mut self) -> bool { + let changed = self.editor.redo(); + if changed { + self.bump_content_version(); + } + changed + } + + pub fn textarea_move_word_left(&mut self) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::WordBack); + }) + } + + pub fn textarea_move_word_right(&mut self) -> bool { + self.apply_cursor_edit(|textarea| { + textarea.move_cursor(CursorMove::WordForward); + }) + } + + pub fn textarea_delete_word_before(&mut self) -> bool { + let changed = self.editor.delete_word(); + if changed { + self.bump_content_version(); + } + changed + } + + pub fn textarea_delete_word_after(&mut self) -> bool { + let changed = self.editor.delete_next_word(); + if changed { + self.bump_content_version(); + } + changed + } + + pub fn insert_newline(&mut self) { + let _ = self.textarea_insert_newline(); + } + + pub fn insert_str(&mut self, s: &str) { + if s.is_empty() { + return; + } + let normalized = normalize_line_endings(s); + if normalized.is_empty() { + return; + } + let changed = self.editor.insert_str(&normalized); + if changed { + self.bump_content_version(); + } + } + + /// Insert a large paste as a compact placeholder token at the cursor. + /// The full text is stored in `paste_blocks` and expanded by `text()` on submit. + /// Returns the placeholder label for display purposes. + pub fn insert_paste_block(&mut self, text: &str) -> String { + let placeholder = self.allocate_paste_block_placeholder(text); + let (cursor_row, cursor_col) = self.cursor(); + let mut lines = self.lines().to_vec(); + let Some(current_line) = lines.get_mut(cursor_row) else { + return placeholder; + }; + let byte_idx = char_to_byte_index(current_line, cursor_col); + current_line.insert_str(byte_idx, &placeholder); + let new_cursor_col = cursor_col + placeholder.chars().count(); + self.replace_lines_and_cursor(lines, cursor_row, new_cursor_col); + placeholder + } + + /// Store the full text as a paste block and return its placeholder label. + pub fn allocate_paste_block_placeholder(&mut self, text: &str) -> String { + let idx = next_free_paste_block_index(self.lines(), &self.paste_blocks); + if idx < self.paste_blocks.len() { + text.clone_into(&mut self.paste_blocks[idx]); + } else { + self.paste_blocks.push(text.to_owned()); + } + paste_placeholder_label(idx, count_text_chars(text)) + } + + /// Append text to the paste block under the cursor if the cursor currently + /// sits at the end of a placeholder token. + /// + /// This handles terminals that deliver one clipboard paste as multiple + /// `Event::Paste` chunks. + pub fn append_to_active_paste_block(&mut self, text: &str) -> bool { + let (cursor_row, cursor_col) = self.cursor(); + let Some(current_line) = self.lines().get(cursor_row).cloned() else { + return false; + }; + let Some((start, end, idx)) = find_placeholder_ending_at_col(¤t_line, cursor_col) + else { + return false; + }; + + let Some(block) = self.paste_blocks.get_mut(idx) else { + return false; + }; + block.push_str(text); + + let head = ¤t_line[..start]; + let tail = ¤t_line[end..]; + let placeholder = paste_placeholder_label(idx, count_text_chars(block)); + let mut lines = self.lines().to_vec(); + lines[cursor_row] = format!("{head}{placeholder}{tail}"); + let new_col = head.chars().count() + placeholder.chars().count(); + self.replace_lines_and_cursor(lines, cursor_row, new_col); + true + } + + pub fn delete_char_before(&mut self) { + let _ = self.textarea_delete_char_before(); + } + + pub fn delete_char_after(&mut self) { + let _ = self.textarea_delete_char_after(); + } + + pub fn move_left(&mut self) { + let _ = self.textarea_move_left(); + } + + pub fn move_right(&mut self) { + let _ = self.textarea_move_right(); + } + + pub fn move_up(&mut self) { + let _ = self.textarea_move_up(); + } + + pub fn move_down(&mut self) { + let _ = self.textarea_move_down(); + } + + pub fn move_home(&mut self) { + if !self.textarea_move_home() { + self.version += 1; + } + } + + pub fn move_end(&mut self) { + if !self.textarea_move_end() { + self.version += 1; + } + } + + pub fn measure_visual_lines(&mut self, content_width: u16, max_rows: u16) -> u16 { + if let Some((v, w, m, result)) = self.cached_measure + && v == self.content_version + && w == content_width + && m == max_rows + { + return result; + } + + let result = if content_width == 0 { + 1 + } else { + self.editor.set_min_rows(1); + self.editor.set_max_rows(max_rows); + self.editor.measure(content_width).preferred_rows + }; + + self.cached_measure = Some((self.content_version, content_width, max_rows, result)); + result + } + + #[must_use] + pub fn line_count(&self) -> u16 { + u16::try_from(self.lines().len()).unwrap_or(u16::MAX) + } +} + +impl Default for InputState { + fn default() -> Self { + Self::new() + } +} + +/// Convert a character index to a byte index within a string. +fn char_to_byte_index(s: &str, char_idx: usize) -> usize { + s.char_indices().nth(char_idx).map_or(s.len(), |(i, _)| i) +} + +/// Normalize mixed line endings to `\n` while preserving trailing blank lines. +fn normalize_line_endings(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\r' { + if chars.peek().is_some_and(|next| *next == '\n') { + let _ = chars.next(); + } + out.push('\n'); + } else { + out.push(ch); + } + } + out +} + +/// Count logical lines for text containing mixed `\n`, `\r`, and `\r\n` endings. +#[cfg(test)] +#[must_use] +fn count_text_lines(text: &str) -> usize { + // Count universal newlines (\n, \r, and \r\n as a single break). + let mut lines = 1; + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\n' => lines += 1, + b'\r' => { + lines += 1; + if i + 1 < bytes.len() && bytes[i + 1] == b'\n' { + i += 1; + } + } + _ => {} + } + i += 1; + } + lines +} + +/// Count Unicode scalar characters in a text payload. +#[must_use] +pub fn count_text_chars(text: &str) -> usize { + text.chars().count() +} + +fn paste_placeholder_label(idx: usize, char_count: usize) -> String { + format!("{PASTE_PREFIX}{} - {char_count} chars{PASTE_SUFFIX}", idx + 1) +} + +fn find_next_placeholder_with_suffix( + line: &str, + search_from: usize, +) -> Option<(usize, usize, usize)> { + let mut start_search = search_from; + while start_search < line.len() { + let rel = line[start_search..].find(PASTE_PREFIX)?; + let start = start_search + rel; + if let Some((idx, end_rel)) = parse_paste_placeholder_with_suffix(&line[start..]) { + return Some((start, start + end_rel, idx)); + } + start_search = start + PASTE_PREFIX.len(); + } + None +} + +fn expand_placeholders_in_line(line: &str, paste_blocks: &[String]) -> String { + let mut out = String::new(); + let mut cursor = 0usize; + while let Some((start, end, idx)) = find_next_placeholder_with_suffix(line, cursor) { + out.push_str(&line[cursor..start]); + if let Some(content) = paste_blocks.get(idx) { + out.push_str(content); + } else { + out.push_str(&line[start..end]); + } + cursor = end; + } + if cursor == 0 { + return line.to_owned(); + } + out.push_str(&line[cursor..]); + out +} + +fn next_free_paste_block_index(lines: &[String], paste_blocks: &[String]) -> usize { + let mut used = vec![false; paste_blocks.len()]; + for line in lines { + let mut cursor = 0usize; + while let Some((_, end, idx)) = find_next_placeholder_with_suffix(line, cursor) { + if idx < used.len() { + used[idx] = true; + } + cursor = end; + } + } + used.iter().position(|in_use| !*in_use).unwrap_or(paste_blocks.len()) +} + +fn find_placeholder_ending_at_col(line: &str, cursor_col: usize) -> Option<(usize, usize, usize)> { + let cursor_byte = char_to_byte_index(line, cursor_col); + let mut search_from = 0usize; + while let Some((start, end, idx)) = find_next_placeholder_with_suffix(line, search_from) { + if end == cursor_byte { + return Some((start, end, idx)); + } + if end > cursor_byte { + return None; + } + search_from = end; + } + None +} + +/// Parse a placeholder at the start of a line. +/// +/// Returns `(paste_block_index, placeholder_end_byte_index)`. +pub fn parse_paste_placeholder_with_suffix(line: &str) -> Option<(usize, usize)> { + let rest = line.strip_prefix(PASTE_PREFIX)?; + let close_rel = rest.find(PASTE_SUFFIX)?; + let rest = &rest[..close_rel]; + let num_str = rest.split(" - ").next()?; + let n: usize = num_str.parse().ok()?; + if n == 0 { + return None; + } + let end = PASTE_PREFIX.len() + close_rel + PASTE_SUFFIX.len(); + Some((n - 1, end)) +} + +/// Parse the placeholder index directly before the cursor, even when the +/// placeholder is embedded within a line. +pub fn parse_paste_placeholder_before_cursor(line: &str, cursor_col: usize) -> Option { + find_placeholder_ending_at_col(line, cursor_col).map(|(_, _, idx)| idx) +} + +/// Return all placeholder highlight ranges in a line as `(start_col, end_col)`. +#[must_use] +pub fn parse_paste_placeholder_ranges(line: &str) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut search_from = 0usize; + while let Some((start, end, _)) = find_next_placeholder_with_suffix(line, search_from) { + let start_col = line[..start].chars().count(); + let end_col = line[..end].chars().count(); + ranges.push((start_col, end_col)); + search_from = end; + } + ranges +} + +#[cfg(test)] +mod tests { + // ===== + // TESTS: 83 + // ===== + + use super::*; + use pretty_assertions::assert_eq; + + // char_to_byte_index + + #[test] + fn char_to_byte_index_ascii() { + assert_eq!(char_to_byte_index("hello", 0), 0); + assert_eq!(char_to_byte_index("hello", 2), 2); + assert_eq!(char_to_byte_index("hello", 5), 5); // past end + } + + #[test] + fn char_to_byte_index_multibyte_utf8() { + // 'e' with accent: 2 bytes in UTF-8 + let s = "cafe\u{0301}"; // "cafe" + combining accent = 5 chars, but accent is 2 bytes + assert_eq!(char_to_byte_index(s, 4), 4); // the combining char starts at byte 4 + } + + #[test] + fn char_to_byte_index_emoji() { + let s = "\u{1F600}hello"; // grinning face (4 bytes) + "hello" + assert_eq!(char_to_byte_index(s, 0), 0); + assert_eq!(char_to_byte_index(s, 1), 4); // after emoji + } + + #[test] + fn char_to_byte_index_beyond_string() { + assert_eq!(char_to_byte_index("ab", 10), 2); // returns s.len() + } + + #[test] + fn char_to_byte_index_empty_string() { + assert_eq!(char_to_byte_index("", 0), 0); + assert_eq!(char_to_byte_index("", 5), 0); + } + + // InputState::new / Default + + #[test] + fn new_creates_empty_state() { + let input = InputState::new(); + assert_eq!(input.lines(), vec![String::new()]); + assert_eq!(input.cursor_row(), 0); + assert_eq!(input.cursor_col(), 0); + assert_eq!(input.version, 0); + } + + #[test] + fn default_equals_new() { + let a = InputState::new(); + let b = InputState::default(); + assert_eq!(a.lines(), b.lines()); + assert_eq!(a.cursor_row(), b.cursor_row()); + assert_eq!(a.cursor_col(), b.cursor_col()); + assert_eq!(a.version, b.version); + } + + // text() + + #[test] + fn text_single_empty_line() { + let input = InputState::new(); + assert_eq!(input.text(), ""); + } + + #[test] + fn text_joins_lines_with_newline() { + let mut input = InputState::new(); + input.insert_str("line1\nline2\nline3"); + assert_eq!(input.text(), "line1\nline2\nline3"); + } + + // is_empty() + + #[test] + fn is_empty_true_for_new() { + assert!(InputState::new().is_empty()); + } + + #[test] + fn is_empty_false_after_insert() { + let mut input = InputState::new(); + input.insert_char('a'); + assert!(!input.is_empty()); + } + + #[test] + fn is_empty_false_for_empty_multiline() { + // Two empty lines: not considered "empty" by the method + let mut input = InputState::new(); + input.insert_newline(); + assert!(!input.is_empty()); + } + + // clear() + + #[test] + fn clear_resets_to_empty() { + let mut input = InputState::new(); + input.insert_str("hello\nworld"); + let v_before = input.version; + input.clear(); + assert!(input.is_empty()); + assert_eq!(input.cursor_row(), 0); + assert_eq!(input.cursor_col(), 0); + assert!(input.version > v_before); + } + + // insert_char + + #[test] + fn insert_char_ascii() { + let mut input = InputState::new(); + input.insert_char('h'); + input.insert_char('i'); + assert_eq!(input.lines()[0], "hi"); + assert_eq!(input.cursor_col(), 2); + } + + #[test] + fn insert_char_unicode_emoji() { + let mut input = InputState::new(); + input.insert_char('\u{1F600}'); // grinning face + assert_eq!(input.cursor_col(), 1); + assert_eq!(input.lines()[0], "\u{1F600}"); + } + + #[test] + fn insert_char_cjk() { + let mut input = InputState::new(); + input.insert_char('\u{4F60}'); // Chinese "ni" + input.insert_char('\u{597D}'); // Chinese "hao" + assert_eq!(input.lines()[0], "\u{4F60}\u{597D}"); + assert_eq!(input.cursor_col(), 2); + } + + #[test] + fn insert_char_mid_line() { + let mut input = InputState::new(); + input.insert_str("ac"); + input.move_left(); // cursor at col 1 + input.insert_char('b'); + assert_eq!(input.lines()[0], "abc"); + assert_eq!(input.cursor_col(), 2); + } + + #[test] + fn insert_char_bumps_version() { + let mut input = InputState::new(); + let v = input.version; + input.insert_char('x'); + assert!(input.version > v); + } + + // insert_newline + + #[test] + fn insert_newline_at_end() { + let mut input = InputState::new(); + input.insert_str("hello"); + input.insert_newline(); + assert_eq!(input.lines(), vec!["hello", ""]); + assert_eq!(input.cursor_row(), 1); + assert_eq!(input.cursor_col(), 0); + } + + #[test] + fn insert_newline_mid_line() { + let mut input = InputState::new(); + input.insert_str("helloworld"); + // Move cursor to position 5 + let _ = input.set_cursor_col(5); + input.insert_newline(); + assert_eq!(input.lines(), vec!["hello", "world"]); + assert_eq!(input.cursor_row(), 1); + assert_eq!(input.cursor_col(), 0); + } + + #[test] + fn insert_newline_at_start() { + let mut input = InputState::new(); + input.insert_str("hello"); + input.move_home(); + input.insert_newline(); + assert_eq!(input.lines(), vec!["", "hello"]); + } + + // insert_str + + #[test] + fn insert_str_multiline() { + let mut input = InputState::new(); + input.insert_str("line1\nline2\nline3"); + assert_eq!(input.lines(), vec!["line1", "line2", "line3"]); + assert_eq!(input.cursor_row(), 2); + assert_eq!(input.cursor_col(), 5); + } + + #[test] + fn insert_str_with_carriage_returns() { + let mut input = InputState::new(); + input.insert_str("a\rb\rc"); + // \r treated same as \n + assert_eq!(input.lines(), vec!["a", "b", "c"]); + } + + #[test] + fn insert_str_empty() { + let mut input = InputState::new(); + let v = input.version; + input.insert_str(""); + assert_eq!(input.version, v); // no mutation + } + + // delete_char_before (backspace) + + #[test] + fn backspace_mid_line() { + let mut input = InputState::new(); + input.insert_str("abc"); + input.delete_char_before(); + assert_eq!(input.lines()[0], "ab"); + assert_eq!(input.cursor_col(), 2); + } + + #[test] + fn backspace_start_of_line_joins() { + let mut input = InputState::new(); + input.insert_str("hello\nworld"); + // cursor at row 1, col 5. Move to start of row 1. + input.move_home(); + input.delete_char_before(); + assert_eq!(input.lines(), vec!["helloworld"]); + assert_eq!(input.cursor_row(), 0); + assert_eq!(input.cursor_col(), 5); // at the join point + } + + #[test] + fn backspace_start_of_buffer_noop() { + let mut input = InputState::new(); + input.insert_str("hi"); + input.move_home(); + let v = input.version; + input.delete_char_before(); // should do nothing + assert_eq!(input.lines()[0], "hi"); + assert_eq!(input.version, v); // no version bump + } + + #[test] + fn backspace_unicode() { + let mut input = InputState::new(); + input.insert_char('\u{1F600}'); + input.insert_char('x'); + input.delete_char_before(); + assert_eq!(input.lines()[0], "\u{1F600}"); + } + + // delete_char_after (delete key) + + #[test] + fn delete_mid_line() { + let mut input = InputState::new(); + input.insert_str("abc"); + input.move_home(); + input.delete_char_after(); + assert_eq!(input.lines()[0], "bc"); + assert_eq!(input.cursor_col(), 0); + } + + #[test] + fn delete_end_of_line_joins_next() { + let mut input = InputState::new(); + input.insert_str("hello\nworld"); + let _ = input.set_cursor(0, 5); // end of "hello" + input.delete_char_after(); + assert_eq!(input.lines(), vec!["helloworld"]); + } + + #[test] + fn delete_end_of_buffer_noop() { + let mut input = InputState::new(); + input.insert_str("hi"); + // cursor at end of last line + let v = input.version; + input.delete_char_after(); + assert_eq!(input.lines()[0], "hi"); + assert_eq!(input.version, v); + } + + // Navigation: move_left, move_right + + #[test] + fn move_left_within_line() { + let mut input = InputState::new(); + input.insert_str("abc"); + input.move_left(); + assert_eq!(input.cursor_col(), 2); + } + + #[test] + fn move_left_wraps_to_previous_line() { + let mut input = InputState::new(); + input.insert_str("ab\ncd"); + input.move_home(); // at col 0, row 1 + input.move_left(); + assert_eq!(input.cursor_row(), 0); + assert_eq!(input.cursor_col(), 2); // end of "ab" + } + + #[test] + fn move_left_at_origin_noop() { + let mut input = InputState::new(); + input.insert_char('a'); + input.move_home(); + let v = input.version; + input.move_left(); + assert_eq!(input.cursor_col(), 0); + assert_eq!(input.cursor_row(), 0); + assert_eq!(input.version, v); // no change + } + + #[test] + fn move_right_within_line() { + let mut input = InputState::new(); + input.insert_str("abc"); + input.move_home(); + input.move_right(); + assert_eq!(input.cursor_col(), 1); + } + + #[test] + fn move_right_wraps_to_next_line() { + let mut input = InputState::new(); + input.insert_str("ab\ncd"); + let _ = input.set_cursor(0, 2); // end of "ab" + input.move_right(); + assert_eq!(input.cursor_row(), 1); + assert_eq!(input.cursor_col(), 0); + } + + #[test] + fn move_right_at_end_noop() { + let mut input = InputState::new(); + input.insert_str("ab"); + let v = input.version; + input.move_right(); // already at end + assert_eq!(input.version, v); + } + + // Navigation: move_up, move_down + + #[test] + fn move_up_clamps_col() { + let mut input = InputState::new(); + input.insert_str("ab\nhello"); + // cursor at row 1, col 5 ("hello" end) + input.move_up(); + assert_eq!(input.cursor_row(), 0); + assert_eq!(input.cursor_col(), 2); // clamped to "ab" length + } + + #[test] + fn move_up_at_top_noop() { + let mut input = InputState::new(); + input.insert_str("hello"); + let v = input.version; + input.move_up(); + assert_eq!(input.cursor_row(), 0); + assert_eq!(input.version, v); + } + + #[test] + fn move_down_clamps_col() { + let mut input = InputState::new(); + input.insert_str("hello\nab"); + let _ = input.set_cursor(0, 5); + input.move_down(); + assert_eq!(input.cursor_row(), 1); + assert_eq!(input.cursor_col(), 2); // clamped to "ab" length + } + + #[test] + fn move_down_at_bottom_noop() { + let mut input = InputState::new(); + input.insert_str("hello"); + let v = input.version; + input.move_down(); + assert_eq!(input.version, v); + } + + // Navigation: move_home, move_end + + #[test] + fn move_home_sets_col_zero() { + let mut input = InputState::new(); + input.insert_str("hello"); + input.move_home(); + assert_eq!(input.cursor_col(), 0); + } + + #[test] + fn move_end_sets_col_to_line_len() { + let mut input = InputState::new(); + input.insert_str("hello"); + input.move_home(); + input.move_end(); + assert_eq!(input.cursor_col(), 5); + } + + #[test] + fn move_home_always_bumps_version() { + let mut input = InputState::new(); + input.insert_str("hello"); + input.move_home(); // col was 5, now 0 + let v = input.version; + input.move_home(); // col already 0, but still bumps + assert!(input.version > v); + } + + // line_count + + #[test] + fn line_count_single() { + assert_eq!(InputState::new().line_count(), 1); + } + + #[test] + fn line_count_multi() { + let mut input = InputState::new(); + input.insert_str("a\nb\nc"); + assert_eq!(input.line_count(), 3); + } + + // version counter + + #[test] + fn version_increments_on_every_mutation() { + let mut input = InputState::new(); + let mut v = input.version; + + input.insert_char('a'); + assert!(input.version > v); + v = input.version; + + input.insert_newline(); + assert!(input.version > v); + v = input.version; + + input.delete_char_before(); + assert!(input.version > v); + v = input.version; + + input.move_left(); + assert!(input.version > v); + v = input.version; + + input.clear(); + assert!(input.version > v); + } + + #[test] + fn rapid_insert_delete_cycle() { + let mut input = InputState::new(); + for _ in 0..100 { + input.insert_char('x'); + } + assert_eq!(input.lines()[0].len(), 100); + for _ in 0..100 { + input.delete_char_before(); + } + assert!(input.is_empty()); + } + + #[test] + fn mixed_unicode_operations() { + let mut input = InputState::new(); + // Insert mixed: ASCII, emoji, CJK + input.insert_str("hi\u{1F600}\u{4F60}"); + assert_eq!(input.cursor_col(), 4); // h, i, emoji, CJK + input.move_home(); + input.move_right(); // past 'h' + input.move_right(); // past 'i' + input.delete_char_after(); // delete emoji + assert_eq!(input.lines()[0], "hi\u{4F60}"); + } + + #[test] + fn multiline_editing_stress() { + let mut input = InputState::new(); + // Create 10 lines + for i in 0..10 { + input.insert_str(&format!("line{i}")); + if i < 9 { + input.insert_newline(); + } + } + assert_eq!(input.lines().len(), 10); + + // Navigate to middle and delete lines by joining + let _ = input.set_cursor(5, 0); + input.delete_char_before(); // join line 5 with line 4 + assert_eq!(input.lines().len(), 9); + + // Text should be coherent + let text = input.text(); + assert!(text.contains("line4line5")); + } + + #[test] + fn insert_str_with_only_newlines() { + let mut input = InputState::new(); + input.insert_str("\n\n\n"); + assert_eq!(input.lines(), vec!["", "", "", ""]); + assert_eq!(input.cursor_row(), 3); + assert_eq!(input.cursor_col(), 0); + } + + #[test] + fn cursor_clamping_on_vertical_nav() { + let mut input = InputState::new(); + input.insert_str("long line here\nab\nmedium line"); + // cursor at row 2, col 11 (end of "medium line") + input.move_up(); // to row 1 "ab", col clamped to 2 + assert_eq!(input.cursor_col(), 2); + input.move_up(); // to row 0 "long line here", col stays 2 + assert_eq!(input.cursor_col(), 2); + input.move_end(); // col = 14 + input.move_down(); // to row 1 "ab", col clamped to 2 + assert_eq!(input.cursor_col(), 2); + } + + // weird inputs + + #[test] + fn insert_tab_character() { + let mut input = InputState::new(); + input.insert_char('\t'); + assert_eq!(input.lines()[0], "\t"); + assert_eq!(input.cursor_col(), 1); + } + + #[test] + fn insert_null_byte() { + let mut input = InputState::new(); + input.insert_char('\0'); + assert_eq!(input.lines()[0].len(), 1); + assert_eq!(input.cursor_col(), 1); + } + + #[test] + fn insert_control_chars() { + let mut input = InputState::new(); + // Bell, backspace-char (not the key), escape + input.insert_char('\x07'); + input.insert_char('\x08'); + input.insert_char('\x1B'); + assert_eq!(input.cursor_col(), 3); + assert_eq!(input.lines()[0].chars().count(), 3); + } + + #[test] + fn windows_crlf_line_endings() { + // \r\n normalizes to a single newline. + let mut input = InputState::new(); + input.insert_str("a\r\nb"); + assert_eq!(input.lines(), vec!["a", "b"]); + } + + #[test] + fn insert_zero_width_joiner_sequence() { + // Family emoji: man + ZWJ + woman + ZWJ + girl + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + let mut input = InputState::new(); + input.insert_str(family); + // Each code point is a separate char as far as Rust is concerned + assert_eq!(input.cursor_col(), family.chars().count()); + assert_eq!(input.text(), family); + } + + #[test] + fn insert_flag_emoji() { + // Regional indicator symbols for US flag + let flag = "\u{1F1FA}\u{1F1F8}"; + let mut input = InputState::new(); + input.insert_str(flag); + assert_eq!(input.cursor_col(), 2); // two chars + assert_eq!(input.text(), flag); + } + + #[test] + fn insert_combining_diacritical_marks() { + // e + combining acute + combining cedilla + let mut input = InputState::new(); + input.insert_char('e'); + input.insert_char('\u{0301}'); // combining acute + input.insert_char('\u{0327}'); // combining cedilla + assert_eq!(input.cursor_col(), 3); + // Delete the last combining mark + input.delete_char_before(); + assert_eq!(input.cursor_col(), 2); + assert_eq!(input.lines()[0], "e\u{0301}"); + } + + #[test] + fn insert_right_to_left_text() { + // Arabic text + let arabic = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; + let mut input = InputState::new(); + input.insert_str(arabic); + assert_eq!(input.cursor_col(), 5); + assert_eq!(input.text(), arabic); + // Navigate and delete should still work + input.move_home(); + input.delete_char_after(); + assert_eq!(input.cursor_col(), 0); + assert_eq!(input.lines()[0].chars().count(), 4); + } + + #[test] + fn insert_very_long_single_line() { + let mut input = InputState::new(); + let long_str: String = "x".repeat(10_000); + input.insert_str(&long_str); + assert_eq!(input.cursor_col(), 10_000); + assert_eq!(input.lines()[0].len(), 10_000); + // Navigate to middle + input.move_home(); + for _ in 0..5000 { + input.move_right(); + } + assert_eq!(input.cursor_col(), 5000); + // Insert in the middle + input.insert_char('Y'); + assert_eq!(input.lines()[0].len(), 10_001); + } + + #[test] + fn insert_many_short_lines() { + let mut input = InputState::new(); + for i in 0..500 { + input.insert_str(&format!("{i}")); + input.insert_newline(); + } + assert_eq!(input.lines().len(), 501); // 500 newlines + 1 trailing empty + assert_eq!(input.cursor_row(), 500); + } + + // rapid key combinations + + #[test] + fn type_then_backspace_all_then_retype() { + let mut input = InputState::new(); + input.insert_str("hello world"); + // Backspace everything + for _ in 0..11 { + input.delete_char_before(); + } + assert!(input.is_empty()); + assert_eq!(input.cursor_col(), 0); + // Type again + input.insert_str("new text"); + assert_eq!(input.text(), "new text"); + } + + #[test] + fn alternating_insert_and_navigate() { + let mut input = InputState::new(); + // Simulate: type 'a', left, type 'b', left, type 'c' -> "cba" + input.insert_char('a'); + input.move_left(); + input.insert_char('b'); + input.move_left(); + input.insert_char('c'); + assert_eq!(input.lines()[0], "cba"); + assert_eq!(input.cursor_col(), 1); // after 'c' + } + + #[test] + fn home_end_rapid_cycle() { + let mut input = InputState::new(); + input.insert_str("hello"); + for _ in 0..50 { + input.move_home(); + assert_eq!(input.cursor_col(), 0); + input.move_end(); + assert_eq!(input.cursor_col(), 5); + } + } + + #[test] + fn left_right_round_trip_preserves_position() { + let mut input = InputState::new(); + input.insert_str("abcdef"); + input.move_home(); + input.move_right(); + input.move_right(); + input.move_right(); // at col 3 + let col = input.cursor_col(); + // Go left 2 then right 2 -- should be back at same spot + input.move_left(); + input.move_left(); + input.move_right(); + input.move_right(); + assert_eq!(input.cursor_col(), col); + } + + #[test] + fn up_down_round_trip_with_short_line() { + let mut input = InputState::new(); + input.insert_str("longline\na\nlongline"); + let _ = input.set_cursor(0, 7); // end-ish of first line + input.move_down(); // to "a" -- col clamped to 1 + assert_eq!(input.cursor_col(), 1); + input.move_down(); // to "longline" -- col stays at 1 (not restored to 7) + assert_eq!(input.cursor_col(), 1); + } + + #[test] + fn newline_then_immediate_backspace() { + let mut input = InputState::new(); + input.insert_str("hello"); + input.insert_newline(); + assert_eq!(input.lines().len(), 2); + input.delete_char_before(); // should rejoin + assert_eq!(input.lines().len(), 1); + assert_eq!(input.lines()[0], "hello"); + assert_eq!(input.cursor_col(), 5); + } + + #[test] + fn delete_forward_through_multiple_line_joins() { + let mut input = InputState::new(); + input.insert_str("a\nb\nc\nd"); + assert_eq!(input.lines().len(), 4); + // Go to very start + let _ = input.set_cursor(0, 0); + // Move to col 1 (after 'a'), then delete forward repeatedly + input.move_right(); // past 'a' + input.delete_char_after(); // join "a" + "b" -> "ab" + assert_eq!(input.lines()[0], "ab"); + input.move_right(); // past 'b' + input.delete_char_after(); // join "ab" + "c" -> "abc" + assert_eq!(input.lines()[0], "abc"); + input.move_right(); // past 'c' + input.delete_char_after(); // join "abc" + "d" -> "abcd" + assert_eq!(input.lines(), vec!["abcd"]); + } + + #[test] + fn backspace_collapses_all_lines_to_one() { + let mut input = InputState::new(); + input.insert_str("a\nb\nc\nd\ne"); + assert_eq!(input.lines().len(), 5); + // Cursor is at end of last line. Backspace everything. + let total_chars = input.text().len(); // includes \n chars + for _ in 0..total_chars { + input.delete_char_before(); + } + assert!(input.is_empty()); + assert_eq!(input.lines().len(), 1); + assert_eq!(input.cursor_row(), 0); + assert_eq!(input.cursor_col(), 0); + } + + // interleaved actions + + #[test] + fn type_on_multiple_lines_then_clear() { + let mut input = InputState::new(); + input.insert_str("line1\nline2\nline3"); + input.move_up(); + input.move_home(); + input.insert_str("prefix_"); + assert_eq!(input.lines()[1], "prefix_line2"); + input.clear(); + assert!(input.is_empty()); + assert_eq!(input.cursor_row(), 0); + } + + #[test] + fn insert_between_emoji() { + let mut input = InputState::new(); + input.insert_char('\u{1F600}'); + input.insert_char('\u{1F601}'); + // cursor at col 2, after both emoji + input.move_left(); // between the two emoji, col 1 + input.insert_char('X'); + assert_eq!(input.lines()[0], "\u{1F600}X\u{1F601}"); + assert_eq!(input.cursor_col(), 2); + } + + #[test] + fn delete_char_after_on_multibyte_boundary() { + let mut input = InputState::new(); + input.insert_str("\u{1F600}\u{1F601}\u{1F602}"); + input.move_home(); + input.move_right(); // after first emoji + input.delete_char_after(); // delete second emoji + assert_eq!(input.lines()[0], "\u{1F600}\u{1F602}"); + } + + #[test] + fn text_consistent_after_every_operation() { + let mut input = InputState::new(); + + input.insert_str("hello"); + assert_eq!(input.text(), "hello"); + + input.insert_newline(); + assert_eq!(input.text(), "hello\n"); + + input.insert_str("world"); + assert_eq!(input.text(), "hello\nworld"); + + input.move_up(); + input.move_end(); + input.insert_char('!'); + assert_eq!(input.text(), "hello!\nworld"); + + input.delete_char_before(); + assert_eq!(input.text(), "hello\nworld"); + + input.move_down(); + input.move_home(); + input.delete_char_before(); // join lines + assert_eq!(input.text(), "helloworld"); + + input.clear(); + assert_eq!(input.text(), ""); + } + + #[test] + fn navigate_through_empty_lines() { + let mut input = InputState::new(); + input.insert_str("\n\n\n"); + // 4 empty lines, cursor at row 3 + assert_eq!(input.cursor_row(), 3); + input.move_up(); + assert_eq!(input.cursor_row(), 2); + assert_eq!(input.cursor_col(), 0); + input.move_up(); + input.move_up(); + assert_eq!(input.cursor_row(), 0); + // Insert on the first empty line + input.insert_char('x'); + assert_eq!(input.lines()[0], "x"); + assert_eq!(input.lines().len(), 4); + } + + #[test] + fn insert_str_into_middle_of_existing_content() { + let mut input = InputState::new(); + input.insert_str("hd"); + input.move_left(); // between h and d + input.insert_str("ello worl"); + assert_eq!(input.lines()[0], "hello world"); + } + + #[test] + fn multiline_paste_into_middle_of_line() { + let mut input = InputState::new(); + input.insert_str("start end"); + // Move cursor to col 6 (between "start " and "end") + input.move_home(); + for _ in 0..6 { + input.move_right(); + } + input.insert_str("line1\nline2\nline3 "); + assert_eq!(input.lines()[0], "start line1"); + assert_eq!(input.lines()[1], "line2"); + assert_eq!(input.lines()[2], "line3 end"); + assert_eq!(input.cursor_row(), 2); + } + + #[test] + fn version_never_wraps_in_reasonable_use() { + let mut input = InputState::new(); + // After 1000 operations version should be 1000 + for _ in 0..500 { + input.insert_char('a'); + input.delete_char_before(); + } + assert_eq!(input.version, 1000); + } + + #[test] + fn mixed_cr_and_lf_in_paste() { + let mut input = InputState::new(); + // Mix of \r, \n, and \r\n + input.insert_str("a\rb\nc\r\nd"); + // \r -> newline, \n -> newline, \r -> newline, \n -> newline + // So: "a", "", "b", "", "c", "", "", "d" -- no wait, let me think again + // \r -> newline (line "a" done, new line), b -> char, \n -> newline, + // c -> char, \r -> newline, \n -> newline, d -> char + // lines: ["a", "b", "c", "", "d"] + assert_eq!(input.lines()[0], "a"); + assert_eq!(input.lines().last().unwrap(), "d"); + // The key point: it doesn't crash and 'd' ends up somewhere + assert!(input.text().contains('d')); + } + + #[test] + fn parse_placeholder_with_trailing_suffix_text() { + let line = "[Pasted Text 2 - 42 chars]tail"; + let parsed = parse_paste_placeholder_with_suffix(line).unwrap(); + assert_eq!(parsed.0, 1); + assert_eq!(&line[..parsed.1], "[Pasted Text 2 - 42 chars]"); + } + + #[test] + fn parse_placeholder_before_cursor_detects_inline_placeholder() { + let line = "a[Pasted Text 2 - 42 chars]tail"; + let cursor_col = "a[Pasted Text 2 - 42 chars]".chars().count(); + assert_eq!(parse_paste_placeholder_before_cursor(line, cursor_col), Some(1)); + } + + #[test] + fn text_expands_placeholder_even_with_trailing_text() { + let mut input = InputState::new(); + input.insert_paste_block("line1\nline2"); + input.mutate_lines(|lines| { + lines[0].push_str(" + extra"); + }); + let _ = input.set_cursor_col(input.lines()[0].chars().count()); + assert_eq!(input.text(), "line1\nline2 + extra"); + } + + #[test] + fn insert_paste_block_inserts_inline_at_cursor() { + let mut input = InputState::new(); + input.insert_str("beforeafter"); + let _ = input.set_cursor_col("before".chars().count()); + input.insert_paste_block(&"x".repeat(1001)); + + assert_eq!(input.lines(), vec!["before[Pasted Text 1 - 1001 chars]after"]); + assert_eq!(input.text(), format!("before{}after", "x".repeat(1001))); + } + + #[test] + fn insert_paste_block_reuses_number_after_placeholder_removed() { + let mut input = InputState::new(); + input.insert_paste_block(&"a".repeat(1001)); + input.mutate_lines(|lines| { + lines[0].clear(); + }); + let _ = input.set_cursor_col(0); + + input.insert_paste_block(&"b".repeat(1001)); + + assert_eq!(input.lines(), vec!["[Pasted Text 1 - 1001 chars]"]); + assert_eq!(input.text(), "b".repeat(1001)); + } + + #[test] + fn append_to_active_paste_block_merges_chunks_and_updates_label() { + let mut input = InputState::new(); + let original = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk"; + input.insert_paste_block(original); + assert!(input.append_to_active_paste_block("\nl\nm")); + assert_eq!(input.lines()[0], "[Pasted Text 1 - 25 chars]"); + assert_eq!(input.text(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm"); + } + + #[test] + fn append_to_active_paste_block_supports_inline_placeholder() { + let mut input = InputState::new(); + input.insert_str("prefix-suffix"); + let _ = input.set_cursor_col("prefix-".chars().count()); + input.insert_paste_block("abc"); + assert_eq!(input.lines()[0], "prefix-[Pasted Text 1 - 3 chars]suffix"); + assert!(input.append_to_active_paste_block("XYZ")); + assert_eq!(input.lines()[0], "prefix-[Pasted Text 1 - 6 chars]suffix"); + assert_eq!(input.text(), "prefix-abcXYZsuffix"); + } + + #[test] + fn append_to_active_paste_block_rejects_dirty_placeholder_line() { + let mut input = InputState::new(); + input.insert_paste_block("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk"); + input.mutate_lines(|lines| { + lines[0].push_str("tail"); + }); + let _ = input.set_cursor_col(input.lines()[0].chars().count()); + assert!(!input.append_to_active_paste_block("x")); + } + + #[test] + fn count_text_lines_handles_mixed_line_endings() { + assert_eq!(count_text_lines("a\r\nb\nc\rd"), 4); + assert_eq!(count_text_lines("single"), 1); + assert_eq!(count_text_lines("x\r\n"), 2); + } + + #[test] + fn count_text_chars_counts_unicode_scalars() { + assert_eq!(count_text_chars("abc"), 3); + assert_eq!(count_text_chars("\u{1F600}\u{4F60}"), 2); + assert_eq!(count_text_chars("a\nb"), 3); + } +} diff --git a/claude-code-rust/src/app/input_submit.rs b/claude-code-rust/src/app/input_submit.rs new file mode 100644 index 0000000..f2fe601 --- /dev/null +++ b/claude-code-rust/src/app/input_submit.rs @@ -0,0 +1,341 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::{App, AppStatus, CancelOrigin, ChatMessage, MessageBlock, MessageRole, TextBlock}; +use crate::agent::events::ClientEvent; +use crate::agent::model; +use crate::app::slash; + +pub(super) fn submit_input(app: &mut App) { + if matches!(app.status, AppStatus::Connecting | AppStatus::CommandPending | AppStatus::Error) { + return; + } + + // Dismiss any open mention dropdown + app.mention = None; + app.slash = None; + app.subagent = None; + + // No connection yet - can't submit + let text = app.input.text(); + if text.trim().is_empty() { + return; + } + + // `/cancel` is an explicit control action: execute immediately. + if slash::is_cancel_command(&text) { + app.pending_auto_submit_after_cancel = false; + app.input.clear(); + dispatch_submission(app, text); + return; + } + + // While a turn is active, keep the current draft text in the input and + // only request cancellation of the running turn. + if is_turn_busy(app) { + match request_cancel(app, CancelOrigin::AutoQueue) { + Ok(()) => { + app.pending_auto_submit_after_cancel = true; + } + Err(message) => { + app.pending_auto_submit_after_cancel = false; + tracing::error!("Failed to request cancel for deferred submit: {message}"); + } + } + return; + } + + app.pending_auto_submit_after_cancel = false; + app.input.clear(); + dispatch_submission(app, text); +} + +fn is_turn_busy(app: &App) -> bool { + matches!(app.status, AppStatus::Thinking | AppStatus::Running) + || app.pending_cancel_origin.is_some() + || app.is_compacting +} + +pub(super) fn request_cancel(app: &mut App, origin: CancelOrigin) -> Result<(), String> { + if matches!(origin, CancelOrigin::Manual) { + app.pending_auto_submit_after_cancel = false; + } + + if !matches!(app.status, AppStatus::Thinking | AppStatus::Running) { + return Ok(()); + } + + if let Some(existing_origin) = app.pending_cancel_origin { + if matches!(existing_origin, CancelOrigin::AutoQueue) + && matches!(origin, CancelOrigin::Manual) + { + app.pending_cancel_origin = Some(CancelOrigin::Manual); + app.cancelled_turn_pending_hint = true; + } + return Ok(()); + } + + let Some(ref conn) = app.conn else { + return Err("not connected yet".to_owned()); + }; + let Some(sid) = app.session_id.clone() else { + return Err("no active session".to_owned()); + }; + + conn.cancel(sid.to_string()).map_err(|e| e.to_string())?; + app.pending_cancel_origin = Some(origin); + app.cancelled_turn_pending_hint = matches!(origin, CancelOrigin::Manual); + let _ = app.event_tx.send(ClientEvent::TurnCancelled); + Ok(()) +} + +pub(super) fn maybe_auto_submit_after_cancel(app: &mut App) { + if !app.pending_auto_submit_after_cancel { + return; + } + if !matches!(app.status, AppStatus::Ready) || app.pending_cancel_origin.is_some() { + return; + } + if app.input.text().trim().is_empty() { + app.pending_auto_submit_after_cancel = false; + return; + } + app.pending_auto_submit_after_cancel = false; + submit_input(app); +} + +fn dispatch_submission(app: &mut App, text: String) { + if slash::try_handle_submit(app, &text) { + return; + } + dispatch_prompt_turn(app, text); +} + +fn dispatch_prompt_turn(app: &mut App, text: String) { + // New turn started by user input: force-stop stale tool calls from older turns + // so their spinners don't continue during this turn. + let _ = app.finalize_in_progress_tool_calls(model::ToolCallStatus::Failed); + + let Some(conn) = app.conn.clone() else { return }; + let Some(sid) = app.session_id.clone() else { + return; + }; + + app.push_message_tracked(ChatMessage { + role: MessageRole::User, + blocks: vec![MessageBlock::Text(TextBlock::from_complete(&text))], + usage: None, + }); + // Create empty assistant message immediately -- message.rs shows thinking indicator + app.push_message_tracked(ChatMessage { + role: MessageRole::Assistant, + blocks: Vec::new(), + usage: None, + }); + app.bind_active_turn_assistant_to_tail(); + app.enforce_history_retention_tracked(); + app.status = AppStatus::Thinking; + app.viewport.engage_auto_scroll(); + + let tx = app.event_tx.clone(); + match conn.prompt_text(sid.to_string(), text) { + Ok(resp) => { + tracing::debug!("Prompt dispatched: stop_reason={:?}", resp.stop_reason); + } + Err(e) => { + let _ = tx.send(ClientEvent::TurnError(e.to_string())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::wire::BridgeCommand; + use crate::app::ActiveView; + + fn app_with_connection() + -> (App, tokio::sync::mpsc::UnboundedReceiver) { + let mut app = App::test_default(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(std::rc::Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some(model::SessionId::new("session-1")); + (app, rx) + } + + #[test] + fn submit_input_while_running_keeps_input_and_requests_cancel() { + let (mut app, mut rx) = app_with_connection(); + app.status = AppStatus::Running; + app.input.set_text("queued prompt"); + + submit_input(&mut app); + + assert_eq!(app.input.text(), "queued prompt"); + assert_eq!(app.pending_cancel_origin, Some(CancelOrigin::AutoQueue)); + assert!(app.pending_auto_submit_after_cancel); + assert!(matches!(app.status, AppStatus::Running)); + assert!(app.messages.is_empty()); + let envelope = rx.try_recv().expect("cancel command should be sent"); + assert!(matches!( + envelope.command, + BridgeCommand::CancelTurn { session_id } if session_id == "session-1" + )); + } + + #[test] + fn manual_cancel_promotes_existing_auto_cancel() { + let (mut app, mut rx) = app_with_connection(); + app.status = AppStatus::Thinking; + app.pending_auto_submit_after_cancel = true; + + request_cancel(&mut app, CancelOrigin::AutoQueue).expect("auto cancel request"); + request_cancel(&mut app, CancelOrigin::Manual).expect("manual cancel request"); + + assert_eq!(app.pending_cancel_origin, Some(CancelOrigin::Manual)); + assert!(app.cancelled_turn_pending_hint); + assert!(!app.pending_auto_submit_after_cancel); + let envelope = rx.try_recv().expect("single cancel command should be sent"); + assert!(matches!( + envelope.command, + BridgeCommand::CancelTurn { session_id } if session_id == "session-1" + )); + assert!(rx.try_recv().is_err(), "manual promotion should not send second cancel"); + } + + #[test] + fn manual_cancel_prevents_later_auto_submit_after_cancel() { + let (mut app, mut rx) = app_with_connection(); + app.status = AppStatus::Running; + app.input.set_text("draft"); + + submit_input(&mut app); + assert_eq!(app.pending_cancel_origin, Some(CancelOrigin::AutoQueue)); + assert!(app.pending_auto_submit_after_cancel); + let cancel = rx.try_recv().expect("cancel command should be sent"); + assert!(matches!( + cancel.command, BridgeCommand::CancelTurn { session_id } if session_id == "session-1" + )); + + request_cancel(&mut app, CancelOrigin::Manual).expect("manual cancel request"); + assert_eq!(app.pending_cancel_origin, Some(CancelOrigin::Manual)); + assert!(!app.pending_auto_submit_after_cancel); + + app.status = AppStatus::Ready; + app.pending_cancel_origin = None; + maybe_auto_submit_after_cancel(&mut app); + + assert_eq!(app.input.text(), "draft"); + assert!(matches!(app.status, AppStatus::Ready)); + assert!(app.messages.is_empty()); + assert!(rx.try_recv().is_err(), "manual cancel should suppress queued prompt submit"); + } + + #[test] + fn submit_input_with_pending_cancel_keeps_input_and_sends_no_second_cancel() { + let (mut app, mut rx) = app_with_connection(); + app.status = AppStatus::Running; + app.input.set_text("draft"); + + submit_input(&mut app); + submit_input(&mut app); + + assert_eq!(app.input.text(), "draft"); + assert_eq!(app.pending_cancel_origin, Some(CancelOrigin::AutoQueue)); + assert!(app.pending_auto_submit_after_cancel); + let envelope = rx.try_recv().expect("first cancel command should be sent"); + assert!(matches!( + envelope.command, BridgeCommand::CancelTurn { session_id } if session_id == "session-1" + )); + assert!(rx.try_recv().is_err(), "second submit should not send extra cancel"); + } + + #[test] + fn submit_input_cancel_command_requests_manual_cancel() { + let (mut app, mut rx) = app_with_connection(); + app.status = AppStatus::Running; + app.input.set_text("/cancel"); + + submit_input(&mut app); + + assert!(app.input.text().is_empty()); + assert_eq!(app.pending_cancel_origin, Some(CancelOrigin::Manual)); + let envelope = rx.try_recv().expect("cancel command should be sent"); + assert!(matches!( + envelope.command, + BridgeCommand::CancelTurn { session_id } if session_id == "session-1" + )); + } + + #[test] + fn auto_submit_dispatches_draft_once_ready() { + let (mut app, mut rx) = app_with_connection(); + app.status = AppStatus::Running; + app.input.set_text("send after cancel"); + + submit_input(&mut app); + assert!(app.pending_auto_submit_after_cancel); + let cancel = rx.try_recv().expect("cancel command should be sent"); + assert!(matches!( + cancel.command, BridgeCommand::CancelTurn { session_id } if session_id == "session-1" + )); + + app.status = AppStatus::Ready; + app.pending_cancel_origin = None; + maybe_auto_submit_after_cancel(&mut app); + + assert!(!app.pending_auto_submit_after_cancel); + assert!(app.input.text().is_empty()); + assert!(matches!(app.status, AppStatus::Thinking)); + assert_eq!(app.messages.len(), 2); + let prompt = rx.try_recv().expect("prompt command should be sent"); + assert!(matches!( + prompt.command, + BridgeCommand::Prompt { session_id, .. } if session_id == "session-1" + )); + } + + #[test] + fn auto_submit_opens_config_only_after_cancel_finishes() { + let (mut app, mut rx) = app_with_connection(); + let dir = tempfile::tempdir().expect("tempdir"); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.cwd_raw = dir.path().to_string_lossy().to_string(); + app.status = AppStatus::Running; + app.input.set_text("/config"); + + submit_input(&mut app); + + assert_eq!(app.active_view, ActiveView::Chat); + assert_eq!(app.input.text(), "/config"); + assert_eq!(app.pending_cancel_origin, Some(CancelOrigin::AutoQueue)); + assert!(app.pending_auto_submit_after_cancel); + let cancel = rx.try_recv().expect("cancel command should be sent"); + assert!(matches!( + cancel.command, BridgeCommand::CancelTurn { session_id } if session_id == "session-1" + )); + + app.status = AppStatus::Ready; + app.pending_cancel_origin = None; + maybe_auto_submit_after_cancel(&mut app); + + assert!(!app.pending_auto_submit_after_cancel); + assert_eq!(app.active_view, ActiveView::Config); + assert!(app.input.text().is_empty()); + assert!(matches!(app.status, AppStatus::Ready)); + assert!(rx.try_recv().is_err(), "config open should not dispatch a prompt turn"); + } + + #[test] + fn dispatch_prompt_turn_without_session_id_leaves_state_unchanged() { + let mut app = App::test_default(); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(std::rc::Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.status = AppStatus::Ready; + + dispatch_prompt_turn(&mut app, "hello".into()); + + assert!(app.messages.is_empty()); + assert!(matches!(app.status, AppStatus::Ready)); + } +} diff --git a/claude-code-rust/src/app/keys.rs b/claude-code-rust/src/app/keys.rs new file mode 100644 index 0000000..ee7dd03 --- /dev/null +++ b/claude-code-rust/src/app/keys.rs @@ -0,0 +1,956 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::dialog::DialogState; +use super::paste_burst::CharAction; +use super::{ + App, AppStatus, CancelOrigin, FocusOwner, FocusTarget, HelpView, InvalidationLevel, ModeInfo, + ModeState, +}; +use crate::app::inline_interactions::handle_inline_interaction_key; +use crate::app::selection::{clear_selection, selection_text_from_rendered_lines}; +use crate::app::state::AutocompleteKind; +use crate::app::{mention, slash, subagent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +#[cfg(test)] +use std::cell::Cell; +use std::rc::Rc; +use std::time::Instant; + +const HELP_TAB_PREV_KEY: KeyCode = KeyCode::Left; +const HELP_TAB_NEXT_KEY: KeyCode = KeyCode::Right; + +fn is_ctrl_shortcut(modifiers: KeyModifiers) -> bool { + modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) +} + +fn is_ctrl_char_shortcut(key: KeyEvent, expected: char) -> bool { + is_ctrl_shortcut(key.modifiers) + && matches!(key.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&expected)) +} + +fn is_permission_ctrl_shortcut(key: KeyEvent) -> bool { + is_ctrl_char_shortcut(key, 'y') + || is_ctrl_char_shortcut(key, 'a') + || is_ctrl_char_shortcut(key, 'n') +} + +fn handle_always_allowed_shortcuts(app: &mut App, key: KeyEvent) -> bool { + if is_ctrl_char_shortcut(key, 'q') { + app.should_quit = true; + return true; + } + if is_ctrl_char_shortcut(key, 'c') { + match copy_selection_to_clipboard(app) { + ClipboardCopyResult::Copied => { + clear_selection(app); + return true; + } + ClipboardCopyResult::Failed => { + return true; + } + ClipboardCopyResult::NoText => {} + } + app.should_quit = true; + return true; + } + false +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClipboardCopyResult { + Copied, + Failed, + NoText, +} + +fn copy_selection_to_clipboard(app: &mut App) -> ClipboardCopyResult { + let Some(selected_text) = selection_text_for_copy(app) else { + return ClipboardCopyResult::NoText; + }; + + write_text_to_clipboard(selected_text) +} + +fn write_text_to_clipboard(selected_text: String) -> ClipboardCopyResult { + #[cfg(test)] + { + match TEST_CLIPBOARD_MODE.with(Cell::get) { + TestClipboardMode::Succeed => return ClipboardCopyResult::Copied, + TestClipboardMode::Fail => return ClipboardCopyResult::Failed, + TestClipboardMode::System => {} + } + } + + let Ok(mut clipboard) = arboard::Clipboard::new() else { + tracing::warn!("failed to access clipboard while copying selection"); + return ClipboardCopyResult::Failed; + }; + + if clipboard.set_text(selected_text).is_ok() { + ClipboardCopyResult::Copied + } else { + tracing::warn!("failed to write selection text to clipboard"); + ClipboardCopyResult::Failed + } +} + +fn selection_text_for_copy(app: &mut App) -> Option { + let selection = app.selection?; + crate::ui::refresh_selection_snapshot(app); + let lines = match selection.kind { + super::SelectionKind::Chat => &app.rendered_chat_lines, + super::SelectionKind::Input => &app.rendered_input_lines, + }; + let selected_text = selection_text_from_rendered_lines(lines, selection); + (!selected_text.is_empty()).then_some(selected_text) +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TestClipboardMode { + System, + Succeed, + Fail, +} + +#[cfg(test)] +thread_local! { + static TEST_CLIPBOARD_MODE: Cell = const { Cell::new(TestClipboardMode::System) }; +} + +#[cfg(test)] +pub(crate) struct TestClipboardGuard { + previous: TestClipboardMode, +} + +#[cfg(test)] +impl Drop for TestClipboardGuard { + fn drop(&mut self) { + TEST_CLIPBOARD_MODE.with(|mode| mode.set(self.previous)); + } +} + +#[cfg(test)] +pub(crate) fn override_test_clipboard(mode: TestClipboardMode) -> TestClipboardGuard { + let previous = TEST_CLIPBOARD_MODE.with(|current| { + let previous = current.get(); + current.set(mode); + previous + }); + TestClipboardGuard { previous } +} + +pub(super) fn dispatch_key_by_focus(app: &mut App, key: KeyEvent) -> bool { + if handle_always_allowed_shortcuts(app, key) { + return true; + } + + if matches!(app.status, AppStatus::Connecting | AppStatus::CommandPending | AppStatus::Error) + || app.is_compacting + { + return handle_blocked_input_shortcuts(app, key); + } + + sync_help_focus(app); + + if handle_global_shortcuts(app, key) { + return true; + } + + match app.focus_owner() { + FocusOwner::Mention => handle_autocomplete_key(app, key), + FocusOwner::Help => handle_help_key(app, key), + FocusOwner::Permission => { + if handle_inline_interaction_key(app, key) { + true + } else { + handle_normal_key(app, key) + } + } + FocusOwner::Input | FocusOwner::TodoList => handle_normal_key(app, key), + } +} + +/// During blocked-input states (Connecting, `CommandPending`, Error), keep input disabled and only allow +/// navigation/help shortcuts. +fn handle_blocked_input_shortcuts(app: &mut App, key: KeyEvent) -> bool { + if is_ctrl_char_shortcut(key, 'u') && app.update_check_hint.is_some() { + app.update_check_hint = None; + sync_help_focus(app); + return true; + } + + if is_ctrl_char_shortcut(key, 'l') { + app.force_redraw = true; + sync_help_focus(app); + return true; + } + + let changed = match (key.code, key.modifiers) { + (KeyCode::Char('?'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + if app.is_help_active() { + app.help_open = false; + app.input.clear(); + } else { + app.help_open = true; + app.input.set_text("?"); + } + true + } + (HELP_TAB_PREV_KEY, m) if m == KeyModifiers::NONE && app.is_help_active() => { + set_help_view(app, prev_help_view(app.help_view)); + true + } + (HELP_TAB_NEXT_KEY, m) if m == KeyModifiers::NONE && app.is_help_active() => { + set_help_view(app, next_help_view(app.help_view)); + true + } + (KeyCode::Up, m) if m == KeyModifiers::NONE || m == KeyModifiers::CONTROL => { + app.viewport.scroll_up(1); + true + } + (KeyCode::Down, m) if m == KeyModifiers::NONE || m == KeyModifiers::CONTROL => { + app.viewport.scroll_down(1); + true + } + _ => false, + }; + + sync_help_focus(app); + changed +} + +/// Handle shortcuts that should work regardless of current focus owner. +fn handle_global_shortcuts(app: &mut App, key: KeyEvent) -> bool { + // Session-only dismiss for update hint. + if is_ctrl_char_shortcut(key, 'u') && app.update_check_hint.is_some() { + app.update_check_hint = None; + return true; + } + + // Permission quick shortcuts are global when permissions are pending. + if !app.pending_interaction_ids.is_empty() && is_permission_ctrl_shortcut(key) { + return handle_inline_interaction_key(app, key); + } + + match (key.code, key.modifiers) { + (KeyCode::Char('t'), m) if m == KeyModifiers::CONTROL => { + toggle_todo_panel_focus(app); + true + } + (KeyCode::Char('o'), m) if m == KeyModifiers::CONTROL => { + toggle_all_tool_calls(app); + true + } + (KeyCode::Char('l'), m) if m == KeyModifiers::CONTROL => { + app.force_redraw = true; + true + } + (KeyCode::Up, m) if m == KeyModifiers::CONTROL => { + app.viewport.scroll_up(1); + true + } + (KeyCode::Down, m) if m == KeyModifiers::CONTROL => { + app.viewport.scroll_down(1); + true + } + _ => false, + } +} + +#[inline] +pub(super) fn is_printable_text_modifiers(modifiers: KeyModifiers) -> bool { + let ctrl_alt = + modifiers.contains(KeyModifiers::CONTROL) && modifiers.contains(KeyModifiers::ALT); + !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) || ctrl_alt +} + +pub(super) fn handle_normal_key(app: &mut App, key: KeyEvent) -> bool { + sync_help_focus(app); + let input_version_before = app.input.version; + + if should_ignore_key_during_paste(app, key) { + return false; + } + + let changed = handle_normal_key_actions(app, key); + + if app.input.version != input_version_before { + sync_help_open_after_input_change(app); + } + + if app.input.version != input_version_before && should_sync_autocomplete_after_key(app, key) { + mention::sync_with_cursor(app); + slash::sync_with_cursor(app); + subagent::sync_with_cursor(app); + } + + sync_help_focus(app); + changed +} + +fn should_ignore_key_during_paste(app: &mut App, key: KeyEvent) -> bool { + if app.pending_submit.is_some() && is_editing_like_key(key) { + app.pending_submit = None; + } + !app.pending_paste_text.is_empty() && is_editing_like_key(key) +} + +fn is_editing_like_key(key: KeyEvent) -> bool { + matches!( + key.code, + KeyCode::Char(_) | KeyCode::Enter | KeyCode::Tab | KeyCode::Backspace | KeyCode::Delete + ) +} + +fn handle_normal_key_actions(app: &mut App, key: KeyEvent) -> bool { + if handle_turn_control_key(app, key) { + return true; + } + if handle_submit_key(app, key) { + return true; + } + if handle_history_key(app, key) { + return true; + } + if handle_navigation_key(app, key) { + return true; + } + if handle_focus_toggle_key(app, key) { + return true; + } + if handle_mode_cycle_key(app, key) { + return true; + } + if handle_editing_key(app, key) { + return true; + } + handle_printable_key(app, key) +} + +fn handle_turn_control_key(app: &mut App, key: KeyEvent) -> bool { + if !matches!(key.code, KeyCode::Esc) { + return false; + } + app.pending_submit = None; + if app.focus_owner() == FocusOwner::TodoList { + app.release_focus_target(FocusTarget::TodoList); + return true; + } + if matches!(app.status, AppStatus::Thinking | AppStatus::Running) + && let Err(message) = super::input_submit::request_cancel(app, CancelOrigin::Manual) + { + tracing::error!("Failed to send cancel: {message}"); + } + true +} + +fn handle_submit_key(app: &mut App, key: KeyEvent) -> bool { + if !matches!(key.code, KeyCode::Enter) || app.focus_owner() == FocusOwner::TodoList { + return false; + } + + let now = Instant::now(); + + // During an active burst or the post-burst suppression window, Enter + // becomes a newline to keep multi-line pastes grouped. + if app.paste_burst.on_enter(now) { + tracing::debug!("paste_enter: enter routed through paste buffer"); + return true; + } + + if !key.modifiers.contains(KeyModifiers::SHIFT) + && !key.modifiers.contains(KeyModifiers::CONTROL) + { + app.pending_submit = Some(app.input.snapshot()); + tracing::debug!("paste_enter: armed deferred submit snapshot"); + return false; + } + app.pending_submit = None; + tracing::debug!("paste_enter: inserted explicit newline"); + app.input.textarea_insert_newline() +} + +fn handle_history_key(app: &mut App, key: KeyEvent) -> bool { + if app.focus_owner() == FocusOwner::TodoList { + return false; + } + match (key.code, key.modifiers) { + (KeyCode::Char('z'), m) if m == KeyModifiers::CONTROL => app.input.textarea_undo(), + (KeyCode::Char('y'), m) if m == KeyModifiers::CONTROL => app.input.textarea_redo(), + _ => false, + } +} + +fn handle_navigation_key(app: &mut App, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (KeyCode::Left, m) + if app.focus_owner() != FocusOwner::TodoList + && m.contains(KeyModifiers::CONTROL) + && !m.contains(KeyModifiers::ALT) => + { + app.input.textarea_move_word_left() + } + (KeyCode::Right, m) + if app.focus_owner() != FocusOwner::TodoList + && m.contains(KeyModifiers::CONTROL) + && !m.contains(KeyModifiers::ALT) => + { + app.input.textarea_move_word_right() + } + (KeyCode::Left, _) if app.focus_owner() != FocusOwner::TodoList => { + app.input.textarea_move_left() + } + (KeyCode::Right, _) if app.focus_owner() != FocusOwner::TodoList => { + app.input.textarea_move_right() + } + (KeyCode::Up, _) if app.focus_owner() == FocusOwner::TodoList => { + move_todo_selection_up(app); + true + } + (KeyCode::Down, _) if app.focus_owner() == FocusOwner::TodoList => { + move_todo_selection_down(app); + true + } + (KeyCode::Up, _) => { + if !try_move_input_cursor_up(app) { + app.viewport.scroll_up(1); + } + true + } + (KeyCode::Down, _) => { + if !try_move_input_cursor_down(app) { + app.viewport.scroll_down(1); + } + true + } + (KeyCode::Home, _) if app.focus_owner() != FocusOwner::TodoList => { + app.input.textarea_move_home() + } + (KeyCode::End, _) if app.focus_owner() != FocusOwner::TodoList => { + app.input.textarea_move_end() + } + _ => false, + } +} + +fn handle_focus_toggle_key(app: &mut App, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (KeyCode::Tab, m) + if !m.contains(KeyModifiers::SHIFT) + && !m.contains(KeyModifiers::CONTROL) + && !m.contains(KeyModifiers::ALT) + && app.show_todo_panel + && !app.todos.is_empty() => + { + if app.focus_owner() == FocusOwner::TodoList { + app.release_focus_target(FocusTarget::TodoList); + } else { + app.claim_focus_target(FocusTarget::TodoList); + } + true + } + _ => false, + } +} + +fn handle_mode_cycle_key(app: &mut App, key: KeyEvent) -> bool { + if !matches!(key.code, KeyCode::BackTab) { + return false; + } + let Some(ref mode) = app.mode else { + return true; + }; + if mode.available_modes.len() <= 1 { + return true; + } + + let current_idx = + mode.available_modes.iter().position(|m| m.id == mode.current_mode_id).unwrap_or(0); + let next_idx = (current_idx + 1) % mode.available_modes.len(); + let next = &mode.available_modes[next_idx]; + + if let Some(ref conn) = app.conn + && let Some(sid) = app.session_id.clone() + { + let mode_id = next.id.clone(); + let conn = Rc::clone(conn); + tokio::task::spawn_local(async move { + if let Err(e) = conn.set_mode(sid.to_string(), mode_id) { + tracing::error!("Failed to set mode: {e}"); + } + }); + } + + let next_id = next.id.clone(); + let next_name = next.name.clone(); + let modes = mode + .available_modes + .iter() + .map(|m| ModeInfo { id: m.id.clone(), name: m.name.clone() }) + .collect(); + app.mode = Some(ModeState { + current_mode_id: next_id, + current_mode_name: next_name, + available_modes: modes, + }); + true +} + +fn handle_editing_key(app: &mut App, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (KeyCode::Backspace, m) + if app.focus_owner() != FocusOwner::TodoList + && m.contains(KeyModifiers::CONTROL) + && !m.contains(KeyModifiers::ALT) => + { + app.input.textarea_delete_word_before() + } + (KeyCode::Delete, m) + if app.focus_owner() != FocusOwner::TodoList + && m.contains(KeyModifiers::CONTROL) + && !m.contains(KeyModifiers::ALT) => + { + app.input.textarea_delete_word_after() + } + (KeyCode::Backspace, _) if app.focus_owner() != FocusOwner::TodoList => { + app.input.textarea_delete_char_before() + } + (KeyCode::Delete, _) if app.focus_owner() != FocusOwner::TodoList => { + app.input.textarea_delete_char_after() + } + _ => false, + } +} + +fn handle_printable_key(app: &mut App, key: KeyEvent) -> bool { + let (KeyCode::Char(c), m) = (key.code, key.modifiers) else { + // Non-char key: reset burst state to prevent leakage. + app.paste_burst.on_non_char_key(Instant::now()); + return false; + }; + if !is_printable_text_modifiers(m) { + return false; + } + if app.focus_owner() == FocusOwner::TodoList { + app.release_focus_target(FocusTarget::TodoList); + } + + let now = Instant::now(); + match app.paste_burst.on_char(c, now) { + CharAction::Consumed => { + // Character absorbed into burst buffer. Don't insert. + tracing::debug!(ch = %c.escape_default(), "paste_key: consumed char into burst"); + return false; + } + CharAction::RetroCapture(delete_count) => { + // Burst confirmation retro-captured already-inserted leading chars. + for _ in 0..delete_count { + let _ = app.input.textarea_delete_char_before(); + } + tracing::debug!( + ch = %c.escape_default(), + delete_count, + "paste_key: retro-captured leaked chars" + ); + return true; + } + CharAction::Passthrough(ch) => { + // Normal typing or a previously-held char released. + // If `ch == c`, single normal insert. Otherwise the detector + // emitted a held char; insert it first, then the current char. + tracing::debug!( + input = %c.escape_default(), + emitted = %ch.escape_default(), + "paste_key: passthrough" + ); + if ch == c { + let _ = app.input.textarea_insert_char(c); + } else { + let _ = app.input.textarea_insert_char(ch); + let _ = app.input.textarea_insert_char(c); + } + } + } + + if c == '?' && app.input.text().trim() == "?" { + app.help_open = true; + } + + if c == '@' { + mention::activate(app); + } else if c == '/' { + slash::activate(app); + } else if c == '&' { + subagent::activate(app); + } + true +} + +fn sync_help_open_after_input_change(app: &mut App) { + if app.is_help_active() && app.input.text().trim() != "?" { + app.help_open = false; + } +} + +fn try_move_input_cursor_up(app: &mut App) -> bool { + let before = (app.input.cursor_row(), app.input.cursor_col()); + let _ = app.input.textarea_move_up(); + (app.input.cursor_row(), app.input.cursor_col()) != before +} + +fn try_move_input_cursor_down(app: &mut App) -> bool { + let before = (app.input.cursor_row(), app.input.cursor_col()); + let _ = app.input.textarea_move_down(); + (app.input.cursor_row(), app.input.cursor_col()) != before +} + +fn should_sync_autocomplete_after_key(app: &App, key: KeyEvent) -> bool { + if app.focus_owner() == FocusOwner::TodoList { + return false; + } + + match (key.code, key.modifiers) { + ( + KeyCode::Up + | KeyCode::Down + | KeyCode::Left + | KeyCode::Right + | KeyCode::Home + | KeyCode::End + | KeyCode::Backspace + | KeyCode::Delete + | KeyCode::Enter, + _, + ) => true, + (KeyCode::Char('z' | 'y'), m) if m == KeyModifiers::CONTROL => true, + (KeyCode::Char(_), m) if is_printable_text_modifiers(m) => true, + _ => false, + } +} + +pub(super) fn toggle_todo_panel_focus(app: &mut App) { + if app.todos.is_empty() { + app.show_todo_panel = false; + app.release_focus_target(FocusTarget::TodoList); + app.todo_scroll = 0; + app.todo_selected = 0; + return; + } + + app.show_todo_panel = !app.show_todo_panel; + if app.show_todo_panel { + // Start at in-progress todo when available; fallback to first item. + app.todo_selected = + app.todos.iter().position(|t| t.status == super::TodoStatus::InProgress).unwrap_or(0); + app.claim_focus_target(FocusTarget::TodoList); + } else { + app.release_focus_target(FocusTarget::TodoList); + } +} + +pub(super) fn move_todo_selection_up(app: &mut App) { + if app.todos.is_empty() || !app.show_todo_panel { + app.release_focus_target(FocusTarget::TodoList); + return; + } + app.todo_selected = app.todo_selected.saturating_sub(1); +} + +pub(super) fn move_todo_selection_down(app: &mut App) { + if app.todos.is_empty() || !app.show_todo_panel { + app.release_focus_target(FocusTarget::TodoList); + return; + } + let max = app.todos.len().saturating_sub(1); + if app.todo_selected < max { + app.todo_selected += 1; + } +} + +/// Handle keystrokes while mention/slash autocomplete dropdown is active. +pub(super) fn handle_autocomplete_key(app: &mut App, key: KeyEvent) -> bool { + match app.active_autocomplete_kind() { + Some(AutocompleteKind::Mention) => return handle_mention_key(app, key), + Some(AutocompleteKind::Slash) => return handle_slash_key(app, key), + Some(AutocompleteKind::Subagent) => return handle_subagent_key(app, key), + None => {} + } + dispatch_key_by_focus(app, key) +} + +fn handle_help_key(app: &mut App, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (HELP_TAB_PREV_KEY, m) if m == KeyModifiers::NONE => { + set_help_view(app, prev_help_view(app.help_view)); + true + } + (HELP_TAB_NEXT_KEY, m) if m == KeyModifiers::NONE => { + set_help_view(app, next_help_view(app.help_view)); + true + } + (KeyCode::Up, m) if m == KeyModifiers::NONE => { + if matches!(app.help_view, HelpView::SlashCommands | HelpView::Subagents) { + let count = crate::ui::help::help_item_count(app); + app.help_dialog.move_up(count, app.help_visible_count); + } + true + } + (KeyCode::Down, m) if m == KeyModifiers::NONE => { + if matches!(app.help_view, HelpView::SlashCommands | HelpView::Subagents) { + let count = crate::ui::help::help_item_count(app); + app.help_dialog.move_down(count, app.help_visible_count); + } + true + } + _ => handle_normal_key(app, key), + } +} + +const fn next_help_view(current: HelpView) -> HelpView { + match current { + HelpView::Keys => HelpView::SlashCommands, + HelpView::SlashCommands => HelpView::Subagents, + HelpView::Subagents => HelpView::Keys, + } +} + +const fn prev_help_view(current: HelpView) -> HelpView { + match current { + HelpView::Keys => HelpView::Subagents, + HelpView::SlashCommands => HelpView::Keys, + HelpView::Subagents => HelpView::SlashCommands, + } +} + +fn set_help_view(app: &mut App, next: HelpView) { + if app.help_view != next { + tracing::debug!(from = ?app.help_view, to = ?next, "Help view changed via keyboard"); + app.help_view = next; + app.help_dialog = DialogState::default(); + } +} + +fn sync_help_focus(app: &mut App) { + if app.is_help_active() + && app.pending_interaction_ids.is_empty() + && !app.autocomplete_focus_available() + { + app.claim_focus_target(FocusTarget::Help); + } else { + app.release_focus_target(FocusTarget::Help); + } +} + +/// Handle keystrokes while the `@` mention autocomplete dropdown is active. +pub(super) fn handle_mention_key(app: &mut App, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (KeyCode::Up, _) => { + mention::move_up(app); + true + } + (KeyCode::Down, _) => { + mention::move_down(app); + true + } + (KeyCode::Enter | KeyCode::Tab, _) => { + mention::confirm_selection(app); + true + } + (KeyCode::Esc, _) => { + mention::deactivate(app); + true + } + (KeyCode::Backspace, _) => { + let changed = app.input.textarea_delete_char_before(); + mention::update_query(app); + changed + } + (KeyCode::Char(c), m) if is_printable_text_modifiers(m) => { + let changed = app.input.textarea_insert_char(c); + if c.is_whitespace() { + mention::deactivate(app); + } else { + mention::update_query(app); + } + changed + } + // Any other key: deactivate mention and forward to normal handling + _ => { + mention::deactivate(app); + dispatch_key_by_focus(app, key) + } + } +} + +/// Handle keystrokes while slash autocomplete dropdown is active. +fn handle_slash_key(app: &mut App, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (KeyCode::Up, _) => { + slash::move_up(app); + true + } + (KeyCode::Down, _) => { + slash::move_down(app); + true + } + (KeyCode::Enter | KeyCode::Tab, _) => { + slash::confirm_selection(app); + true + } + (KeyCode::Esc, _) => { + slash::deactivate(app); + true + } + (KeyCode::Backspace, _) => { + let changed = app.input.textarea_delete_char_before(); + slash::update_query(app); + changed + } + (KeyCode::Char(c), m) if is_printable_text_modifiers(m) => { + let changed = app.input.textarea_insert_char(c); + slash::update_query(app); + changed + } + _ => { + slash::deactivate(app); + dispatch_key_by_focus(app, key) + } + } +} + +/// Handle keystrokes while `&` subagent autocomplete dropdown is active. +fn handle_subagent_key(app: &mut App, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (KeyCode::Up, _) => { + subagent::move_up(app); + true + } + (KeyCode::Down, _) => { + subagent::move_down(app); + true + } + (KeyCode::Enter | KeyCode::Tab, _) => { + subagent::confirm_selection(app); + true + } + (KeyCode::Esc, _) => { + subagent::deactivate(app); + true + } + (KeyCode::Backspace, _) => { + let changed = app.input.textarea_delete_char_before(); + subagent::update_query(app); + changed + } + (KeyCode::Char(c), m) if is_printable_text_modifiers(m) => { + let changed = app.input.textarea_insert_char(c); + subagent::update_query(app); + changed + } + _ => { + subagent::deactivate(app); + dispatch_key_by_focus(app, key) + } + } +} + +/// Toggle the session-level collapsed preference for non-Execute tool calls. +pub(super) fn toggle_all_tool_calls(app: &mut App) { + app.tools_collapsed = !app.tools_collapsed; + app.invalidate_layout(InvalidationLevel::Global); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ + ChatMessage, MessageBlock, MessageRole, SelectionKind, SelectionPoint, SelectionState, + TextBlock, + }; + use crossterm::event::{KeyCode, KeyModifiers}; + use ratatui::layout::Rect; + use std::time::{Duration, Instant}; + + #[test] + fn queued_paste_still_blocks_overlapping_key_text() { + let mut app = App::test_default(); + app.pending_paste_text = "clipboard".to_owned(); + + let blocked = should_ignore_key_during_paste( + &mut app, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + assert!(blocked); + } + + #[test] + fn burst_active_does_not_block_followup_chars() { + let mut app = App::test_default(); + let t0 = Instant::now(); + + assert_eq!(app.paste_burst.on_char('a', t0), CharAction::Passthrough('a')); + assert_eq!( + app.paste_burst.on_char('b', t0 + Duration::from_millis(1)), + CharAction::Consumed + ); + assert!(app.paste_burst.is_buffering()); + + let blocked = should_ignore_key_during_paste( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + ); + assert!(!blocked); + } + + #[test] + fn selection_text_for_copy_refreshes_chat_snapshot_before_redraw() { + let mut app = App::test_default(); + app.status = AppStatus::Running; + app.messages.push(ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::Text(TextBlock::from_complete("hello"))], + usage: None, + }); + app.bind_active_turn_assistant(0); + app.rendered_chat_area = Rect::new(0, 0, 20, 6); + app.rendered_chat_lines = vec!["hello".to_owned()]; + app.selection = Some(SelectionState { + kind: SelectionKind::Chat, + start: SelectionPoint { row: 0, col: 0 }, + end: SelectionPoint { row: 0, col: 11 }, + dragging: false, + }); + + if let Some(MessageBlock::Text(block)) = + app.messages.get_mut(0).and_then(|message| message.blocks.get_mut(0)) + { + block.text.push_str(" world"); + block.markdown.append(" world"); + block.cache.invalidate(); + } + app.invalidate_layout(InvalidationLevel::MessageChanged(0)); + + assert!(selection_text_for_copy(&mut app).is_some()); + assert!(app.rendered_chat_lines.iter().any(|line| line.contains("world"))); + } + + #[test] + fn selection_text_for_copy_refreshes_input_snapshot_before_redraw() { + let mut app = App::test_default(); + app.input.set_text("hello"); + app.rendered_input_area = Rect::new(0, 0, 20, 4); + app.rendered_input_lines = vec!["hello".to_owned()]; + app.selection = Some(SelectionState { + kind: SelectionKind::Input, + start: SelectionPoint { row: 0, col: 0 }, + end: SelectionPoint { row: 0, col: 11 }, + dragging: false, + }); + + app.input.set_text("hello world"); + + assert_eq!(selection_text_for_copy(&mut app), Some("hello world".to_owned())); + } +} diff --git a/claude-code-rust/src/app/mention.rs b/claude-code-rust/src/app/mention.rs new file mode 100644 index 0000000..43e2fdb --- /dev/null +++ b/claude-code-rust/src/app/mention.rs @@ -0,0 +1,822 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::{App, FocusTarget, dialog::DialogState}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; +use std::sync::mpsc as std_mpsc; +use std::time::{Instant, SystemTime}; + +/// Maximum candidates shown in the dropdown. +pub const MAX_VISIBLE: usize = 8; + +/// Maximum total candidates kept after filtering. +const MAX_CANDIDATES: usize = 50; +/// Minimum query length before scanning the filesystem for matches. +pub const MIN_QUERY_CHARS: usize = 1; +/// Maximum walker entries drained from the channel per tick. +const DRAIN_BUDGET: usize = 500; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +pub struct MentionState { + /// Character position (row, col) where the `@` was typed. + pub trigger_row: usize, + pub trigger_col: usize, + /// Current query text after the `@` (e.g. "src/m" from "@src/m"). + pub query: String, + /// Filtered + sorted candidates. + pub candidates: Vec, + /// Shared autocomplete dialog navigation state. + pub dialog: DialogState, + search_status: MentionSearchStatus, + /// Cached file walker — persists across query edits. + file_walker: Option, + /// Cache key: (cwd, `respect_gitignore`) that produced the walker. + walker_cache_key: Option<(String, bool)>, +} + +#[derive(Clone)] +pub struct FileCandidate { + /// Relative path from cwd (forward slashes, e.g. "src/main.rs"). + /// Directories have a trailing `/` (e.g. "src/"). + pub rel_path: String, + /// Pre-computed lowercase of `rel_path`. + rel_path_lower: String, + /// Pre-computed lowercase of the basename portion. + basename_lower: String, + /// Depth (number of `/` separators) for grouping. + pub depth: usize, + /// Last modified time for sorting within depth groups. + pub modified: SystemTime, + /// Whether this candidate is a directory (true) or a file (false). + pub is_dir: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MentionSearchStatus { + Hint, + Searching, + Ready, + NoMatches, +} + +// --------------------------------------------------------------------------- +// Background file walker +// --------------------------------------------------------------------------- + +struct FileWalker { + entry_rx: std_mpsc::Receiver, + cancel: Arc, + /// All entries discovered so far (the full file cache). + all_entries: Vec, + /// Whether the background walker has finished. + finished: bool, +} + +impl FileWalker { + fn spawn(root: PathBuf, respect_gitignore: bool) -> Self { + let (entry_tx, entry_rx) = std_mpsc::sync_channel(1024); + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_clone = Arc::clone(&cancel); + + std::thread::spawn(move || { + let mut builder = ignore::WalkBuilder::new(&root); + builder + .hidden(false) + .git_ignore(respect_gitignore) + .git_global(respect_gitignore) + .git_exclude(respect_gitignore) + .sort_by_file_path(std::cmp::Ord::cmp); + + for result in builder.build() { + if cancel_clone.load(AtomicOrdering::Relaxed) { + break; + } + let Ok(entry) = result else { continue }; + + let Some(ft) = entry.file_type() else { continue }; + let is_dir = ft.is_dir(); + let is_file = ft.is_file(); + if !is_dir && !is_file { + continue; + } + + let path = entry.path(); + let Ok(rel) = path.strip_prefix(&root) else { continue }; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if rel_str.is_empty() { + continue; + } + + let depth = rel_str.matches('/').count(); + let rel_path = if is_dir { format!("{rel_str}/") } else { rel_str }; + let modified = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(SystemTime::UNIX_EPOCH); + + let rel_path_lower = rel_path.to_lowercase(); + let basename_lower = candidate_basename(&rel_path).to_lowercase(); + + let candidate = FileCandidate { + rel_path, + rel_path_lower, + basename_lower, + depth, + modified, + is_dir, + }; + + if entry_tx.send(candidate).is_err() { + break; // receiver dropped + } + } + }); + + Self { entry_rx, cancel, all_entries: Vec::new(), finished: false } + } + + /// Drain new entries from the background thread (non-blocking). + /// Returns true if new entries were added. + fn drain(&mut self) -> bool { + if self.finished { + return false; + } + + let mut added = false; + for _ in 0..DRAIN_BUDGET { + match self.entry_rx.try_recv() { + Ok(candidate) => { + self.all_entries.push(candidate); + added = true; + } + Err(std_mpsc::TryRecvError::Empty) => break, + Err(std_mpsc::TryRecvError::Disconnected) => { + self.finished = true; + break; + } + } + } + added + } +} + +impl Drop for FileWalker { + fn drop(&mut self) { + self.cancel.store(true, AtomicOrdering::Relaxed); + } +} + +// --------------------------------------------------------------------------- +// MentionState implementation +// --------------------------------------------------------------------------- + +impl MentionState { + #[must_use] + pub fn new( + trigger_row: usize, + trigger_col: usize, + query: String, + candidates: Vec, + ) -> Self { + let search_status = if candidates.is_empty() { + MentionSearchStatus::Hint + } else { + MentionSearchStatus::Ready + }; + Self { + trigger_row, + trigger_col, + query, + candidates, + dialog: DialogState::default(), + search_status, + file_walker: None, + walker_cache_key: None, + } + } + + #[must_use] + pub fn placeholder_message(&self) -> Option { + if !self.candidates.is_empty() { + return None; + } + + match self.search_status { + MentionSearchStatus::Hint => Some("Type to search files".to_owned()), + MentionSearchStatus::Searching => Some("Searching files...".to_owned()), + MentionSearchStatus::NoMatches => Some("No matching files or folders".to_owned()), + MentionSearchStatus::Ready => None, + } + } + + #[must_use] + pub fn has_selectable_candidates(&self) -> bool { + !self.candidates.is_empty() + } + + fn mark_hint(&mut self) { + self.candidates.clear(); + self.search_status = MentionSearchStatus::Hint; + self.dialog.clamp(0, MAX_VISIBLE); + } + + fn ensure_walker(&mut self, cwd: &str, respect_gitignore: bool) { + let key = (cwd.to_owned(), respect_gitignore); + if self.walker_cache_key.as_ref() == Some(&key) && self.file_walker.is_some() { + return; // reuse existing walker + } + self.file_walker = Some(FileWalker::spawn(PathBuf::from(cwd), respect_gitignore)); + self.walker_cache_key = Some(key); + } + + fn start_search(&mut self, cwd: &str, respect_gitignore: bool) { + self.ensure_walker(cwd, respect_gitignore); + self.refilter(); + } + + fn refilter(&mut self) { + let query_lower = self.query.to_lowercase(); + let Some(walker) = self.file_walker.as_ref() else { + self.candidates.clear(); + self.search_status = MentionSearchStatus::NoMatches; + self.dialog.clamp(0, MAX_VISIBLE); + return; + }; + + // Filter all cached entries against current query + let mut filtered: Vec = walker + .all_entries + .iter() + .filter(|c| match_tier(c, &query_lower).is_some()) + .cloned() + .collect(); + + rank_and_truncate_candidates(&mut filtered, &query_lower); + self.candidates = filtered; + + self.search_status = if walker.finished { + if self.candidates.is_empty() { + MentionSearchStatus::NoMatches + } else { + MentionSearchStatus::Ready + } + } else { + MentionSearchStatus::Searching + }; + self.dialog.clamp(self.candidates.len(), MAX_VISIBLE); + } + + fn advance_search(&mut self) { + let Some(walker) = self.file_walker.as_mut() else { + self.search_status = MentionSearchStatus::NoMatches; + self.dialog.clamp(0, MAX_VISIBLE); + return; + }; + + let added = walker.drain(); + let finished = walker.finished; + + if added || finished { + self.refilter(); + } + } + + fn invalidate_walker_cache(&mut self) { + if let Some(walker) = self.file_walker.take() { + walker.cancel.store(true, AtomicOrdering::Relaxed); + } + self.walker_cache_key = None; + } +} + +// --------------------------------------------------------------------------- +// Matching and ranking +// --------------------------------------------------------------------------- + +fn match_tier(candidate: &FileCandidate, query_lower: &str) -> Option { + if query_lower.is_empty() { + return Some(0); + } + + if candidate.basename_lower.starts_with(query_lower) { + Some(0) + } else if candidate.rel_path_lower.starts_with(query_lower) { + Some(1) + } else if candidate.basename_lower.contains(query_lower) { + Some(2) + } else if candidate.rel_path_lower.contains(query_lower) { + Some(3) + } else { + None + } +} + +fn rank_and_truncate_candidates(candidates: &mut Vec, query_lower: &str) { + // Pre-compute tiers once to avoid repeated calls during sort + let tiers: Vec> = candidates.iter().map(|c| match_tier(c, query_lower)).collect(); + + // Build index array and sort by tier + secondary criteria + let mut indices: Vec = (0..candidates.len()).collect(); + indices.sort_by(|&i, &j| { + tiers[i] + .cmp(&tiers[j]) + .then_with(|| candidates[i].depth.cmp(&candidates[j].depth)) + .then_with(|| candidates[j].is_dir.cmp(&candidates[i].is_dir)) + .then_with(|| candidates[j].modified.cmp(&candidates[i].modified)) + .then_with(|| candidates[i].rel_path.cmp(&candidates[j].rel_path)) + }); + + indices.truncate(MAX_CANDIDATES); + *candidates = indices.into_iter().map(|i| candidates[i].clone()).collect(); +} + +fn candidate_basename(rel_path: &str) -> &str { + let trimmed = rel_path.trim_end_matches('/'); + trimmed.rsplit('/').next().unwrap_or(trimmed) +} + +// --------------------------------------------------------------------------- +// Public API functions +// --------------------------------------------------------------------------- + +/// Detect an `@` mention at the current cursor position. +/// Scans backwards from the cursor to find `@`. The `@` must be preceded by +/// whitespace, a newline, or be at position 0 (to avoid false triggers mid-word). +/// Returns `(trigger_row, trigger_col, query)` where `trigger_col` is the +/// position of the `@` character itself. +pub fn detect_mention_at_cursor( + lines: &[String], + cursor_row: usize, + cursor_col: usize, +) -> Option<(usize, usize, String)> { + let line = lines.get(cursor_row)?; + let chars: Vec = line.chars().collect(); + + let mut i = cursor_col; + while i > 0 { + i -= 1; + let ch = *chars.get(i)?; + if ch == '@' { + if i == 0 || chars.get(i - 1).is_some_and(|c| c.is_whitespace()) { + let query: String = chars[i + 1..cursor_col].iter().collect(); + if query.chars().all(|c| !c.is_whitespace()) { + return Some((cursor_row, i, query)); + } + } + return None; + } + if ch.is_whitespace() { + return None; + } + } + None +} + +/// Activate mention autocomplete after the user types `@`. +pub fn activate(app: &mut App) { + let detection = + detect_mention_at_cursor(app.input.lines(), app.input.cursor_row(), app.input.cursor_col()); + + let Some((trigger_row, trigger_col, query)) = detection else { + return; + }; + + app.mention = Some(MentionState::new(trigger_row, trigger_col, query, Vec::new())); + app.slash = None; + app.subagent = None; + refresh_query_state(app, Instant::now()); +} + +/// Update the query and re-filter candidates while mention is active. +pub fn update_query(app: &mut App) { + let detection = + detect_mention_at_cursor(app.input.lines(), app.input.cursor_row(), app.input.cursor_col()); + + let Some((trigger_row, trigger_col, query)) = detection else { + deactivate(app); + return; + }; + + if let Some(ref mut mention) = app.mention { + mention.trigger_row = trigger_row; + mention.trigger_col = trigger_col; + mention.query = query; + } + + refresh_query_state(app, Instant::now()); +} + +pub fn tick(app: &mut App, now: Instant) { + let Some(mention) = app.mention.as_mut() else { + return; + }; + + match mention.search_status { + MentionSearchStatus::Searching => { + mention.advance_search(); + sync_focus(app); + } + MentionSearchStatus::Hint | MentionSearchStatus::Ready | MentionSearchStatus::NoMatches => { + let _ = now; + } + } +} + +pub fn invalidate_session_cache(app: &mut App) { + if let Some(mention) = app.mention.as_mut() { + mention.invalidate_walker_cache(); + if mention.query.chars().count() < MIN_QUERY_CHARS { + mention.mark_hint(); + } else { + mention.start_search(&app.cwd_raw, app.config.respect_gitignore_effective()); + } + } + sync_focus(app); +} + +fn refresh_query_state(app: &mut App, _now: Instant) { + let Some(mention) = app.mention.as_mut() else { + return; + }; + + if mention.query.chars().count() < MIN_QUERY_CHARS { + mention.mark_hint(); + sync_focus(app); + return; + } + + mention.start_search(&app.cwd_raw, app.config.respect_gitignore_effective()); + sync_focus(app); +} + +fn sync_focus(app: &mut App) { + if app.mention.as_ref().is_some_and(MentionState::has_selectable_candidates) { + app.claim_focus_target(FocusTarget::Mention); + } else { + app.release_focus_target(FocusTarget::Mention); + } +} + +/// Keep mention state in sync with the current cursor location. +/// - If cursor is inside a valid `@mention` token, activate/update autocomplete. +/// - Otherwise, deactivate mention autocomplete. +pub fn sync_with_cursor(app: &mut App) { + let in_mention = + detect_mention_at_cursor(app.input.lines(), app.input.cursor_row(), app.input.cursor_col()) + .is_some(); + match (in_mention, app.mention.is_some()) { + (true, true) => update_query(app), + (true, false) => activate(app), + (false, true) => deactivate(app), + (false, false) => {} + } +} + +/// Confirm the selected candidate: replace `@query` in input with `@rel_path`. +pub fn confirm_selection(app: &mut App) { + let Some(mention) = app.mention.take() else { + return; + }; + app.release_focus_target(FocusTarget::Mention); + + let Some(candidate) = mention.candidates.get(mention.dialog.selected) else { + return; + }; + + let rel_path = candidate.rel_path.clone(); + let trigger_row = mention.trigger_row; + let trigger_col = mention.trigger_col; + + let mut lines = app.input.lines().to_vec(); + let Some(line) = lines.get(trigger_row) else { + return; + }; + let chars: Vec = line.chars().collect(); + if trigger_col >= chars.len() || chars[trigger_col] != '@' { + return; + } + + let mention_end = + (trigger_col + 1..chars.len()).find(|&i| chars[i].is_whitespace()).unwrap_or(chars.len()); + + let before: String = chars[..trigger_col].iter().collect(); + let after: String = chars[mention_end..].iter().collect(); + let replacement = + if after.is_empty() { format!("@{rel_path} ") } else { format!("@{rel_path}") }; + + let new_line = format!("{before}{replacement}{after}"); + let new_cursor_col = trigger_col + replacement.chars().count(); + + lines[trigger_row] = new_line; + app.input.replace_lines_and_cursor(lines, trigger_row, new_cursor_col); +} + +/// Deactivate mention autocomplete. +pub fn deactivate(app: &mut App) { + app.mention = None; + if app.slash.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } +} + +/// Move selection up in the candidate list. +pub fn move_up(app: &mut App) { + if let Some(ref mut mention) = app.mention { + mention.dialog.move_up(mention.candidates.len(), MAX_VISIBLE); + } +} + +/// Move selection down in the candidate list. +pub fn move_down(app: &mut App) { + if let Some(ref mut mention) = app.mention { + mention.dialog.move_down(mention.candidates.len(), MAX_VISIBLE); + } +} + +/// Find all `@path` references in a text string. Returns `(start_byte, end_byte, path)` tuples. +/// A valid `@path` must start after whitespace or at position 0, and extends until +/// the next whitespace or end of string. +pub fn find_mention_spans(text: &str) -> Vec<(usize, usize, String)> { + let mut spans = Vec::new(); + let chars: Vec = text.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '@' && (i == 0 || chars[i - 1].is_whitespace()) { + let start = i; + i += 1; + let path_start = i; + while i < chars.len() && !chars[i].is_whitespace() { + i += 1; + } + if i > path_start { + let path: String = chars[path_start..i].iter().collect(); + let byte_start: usize = chars[..start].iter().map(|c| c.len_utf8()).sum(); + let byte_end: usize = chars[..i].iter().map(|c| c.len_utf8()).sum(); + spans.push((byte_start, byte_end, path)); + } + } else { + i += 1; + } + } + + spans +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use std::time::Duration; + + fn app_with_temp_files(files: &[&str]) -> (App, tempfile::TempDir) { + let tmp = tempfile::tempdir().expect("tempdir"); + for file in files { + let path = tmp.path().join(file); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(&path, "").expect("write file"); + } + let mut app = App::test_default(); + app.cwd_raw = tmp.path().to_string_lossy().into_owned(); + (app, tmp) + } + + fn run_search(app: &mut App, now: Instant) { + for step in 0..200 { + tick(app, now + Duration::from_millis(step)); + // Give the background thread time to discover files + std::thread::sleep(Duration::from_millis(2)); + let is_settled = app.mention.as_ref().is_none_or(|mention| { + !matches!(mention.search_status, MentionSearchStatus::Searching) + }); + if is_settled { + return; + } + } + } + + #[test] + fn sync_with_cursor_activates_inside_existing_mention() { + let (mut app, _tmp) = app_with_temp_files(&["src/main.rs", "tests/integration.rs"]); + app.input.set_text("open @src/main.rs now"); + let _ = app.input.set_cursor(0, "open @src".chars().count()); + + sync_with_cursor(&mut app); + run_search(&mut app, Instant::now()); + + let mention = app.mention.as_ref().expect("mention should be active"); + assert_eq!(mention.query, "src"); + assert!(!mention.candidates.is_empty()); + } + + #[test] + fn confirm_selection_replaces_full_existing_token_without_double_space() { + let (mut app, _tmp) = app_with_temp_files(&["src/lib.rs"]); + app.input.set_text("open @src/lib.txt now"); + let _ = app.input.set_cursor(0, "open @src/lib".chars().count()); + + activate(&mut app); + run_search(&mut app, Instant::now()); + confirm_selection(&mut app); + + assert_eq!(app.input.lines()[0], "open @src/lib.rs now"); + assert!(app.mention.is_none()); + } + + #[test] + fn confirm_selection_at_end_keeps_trailing_space() { + let (mut app, _tmp) = app_with_temp_files(&["src/main.rs"]); + app.input.set_text("@src/mai"); + let _ = app.input.set_cursor(0, app.input.lines()[0].chars().count()); + + activate(&mut app); + run_search(&mut app, Instant::now()); + confirm_selection(&mut app); + + assert_eq!(app.input.lines()[0], "@src/main.rs "); + } + + #[test] + fn activate_with_empty_query_keeps_empty_candidates_until_threshold() { + let (mut app, _tmp) = app_with_temp_files(&["src/main.rs"]); + app.input.set_text("@"); + let _ = app.input.set_cursor(0, 1); + + activate(&mut app); + + let mention = app.mention.as_ref().expect("mention should be active"); + assert_eq!(mention.query, ""); + assert!(mention.candidates.is_empty()); + assert_eq!(mention.placeholder_message().as_deref(), Some("Type to search files")); + } + + #[test] + fn update_query_keeps_active_when_query_becomes_empty() { + let (mut app, _tmp) = app_with_temp_files(&["src/main.rs"]); + app.input.set_text("@src"); + let _ = app.input.set_cursor(0, app.input.lines()[0].chars().count()); + activate(&mut app); + run_search(&mut app, Instant::now()); + assert!(app.mention.is_some()); + + let _ = app.input.set_cursor_col(1); + update_query(&mut app); + + let mention = app.mention.as_ref().expect("mention should stay active"); + assert_eq!(mention.query, ""); + assert!(mention.candidates.is_empty()); + } + + #[test] + fn activate_hides_gitignored_files_by_default() { + let (mut app, tmp) = app_with_temp_files(&["visible.rs", "ignored.rs"]); + std::fs::create_dir_all(tmp.path().join(".git")).expect("create .git"); + std::fs::write(tmp.path().join(".gitignore"), "ignored.rs\n").expect("write .gitignore"); + app.input.set_text("@rs"); + let _ = app.input.set_cursor(0, 3); + + activate(&mut app); + run_search(&mut app, Instant::now()); + + let mention = app.mention.as_ref().expect("mention should be active"); + assert!(mention.candidates.iter().any(|candidate| candidate.rel_path == "visible.rs")); + assert!(!mention.candidates.iter().any(|candidate| candidate.rel_path == "ignored.rs")); + } + + #[test] + fn activate_includes_gitignored_files_when_setting_is_disabled() { + let (mut app, tmp) = app_with_temp_files(&["visible.rs", "ignored.rs"]); + std::fs::create_dir_all(tmp.path().join(".git")).expect("create .git"); + std::fs::write(tmp.path().join(".gitignore"), "ignored.rs\n").expect("write .gitignore"); + crate::app::config::store::set_respect_gitignore( + &mut app.config.committed_preferences_document, + false, + ); + app.input.set_text("@rs"); + let _ = app.input.set_cursor(0, 3); + + activate(&mut app); + run_search(&mut app, Instant::now()); + + let mention = app.mention.as_ref().expect("mention should be active"); + assert!(mention.candidates.iter().any(|candidate| candidate.rel_path == "visible.rs")); + assert!(mention.candidates.iter().any(|candidate| candidate.rel_path == "ignored.rs")); + } + + #[test] + fn nested_gitignore_hides_same_directory_children() { + let (mut app, _tmp) = + app_with_temp_files(&["src/.gitignore", "src/visible.rs", "src/hidden.rs"]); + let root = PathBuf::from(&app.cwd_raw); + std::fs::create_dir_all(root.join(".git")).expect("create .git"); + std::fs::write(root.join("src").join(".gitignore"), "hidden.rs\n") + .expect("write .gitignore"); + app.input.set_text("@rs"); + let _ = app.input.set_cursor(0, 3); + + activate(&mut app); + run_search(&mut app, Instant::now()); + + let mention = app.mention.as_ref().expect("mention should be active"); + assert!(mention.candidates.iter().any(|candidate| candidate.rel_path == "src/visible.rs")); + assert!(!mention.candidates.iter().any(|candidate| candidate.rel_path == "src/hidden.rs")); + } + + #[test] + fn update_query_loads_candidates_once_threshold_is_reached() { + let (mut app, _tmp) = app_with_temp_files(&["src/main.rs"]); + app.input.set_text("@s"); + let _ = app.input.set_cursor(0, 2); + + activate(&mut app); + assert!(app.mention.as_ref().is_some_and(|mention| mention.candidates.is_empty())); + + app.input.set_text("@sr"); + let _ = app.input.set_cursor(0, 3); + update_query(&mut app); + run_search(&mut app, Instant::now()); + + let mention = app.mention.as_ref().expect("mention should remain active"); + assert_eq!(mention.query, "sr"); + assert!(!mention.candidates.is_empty()); + } + + #[test] + fn progressive_search_publishes_shallow_matches_before_deeper_levels() { + let (mut app, _tmp) = + app_with_temp_files(&["root.rs", "src/nested/deep.rs", "src/other.txt"]); + app.input.set_text("@rs"); + let _ = app.input.set_cursor(0, 3); + + activate(&mut app); + + // With the background walker, after full search all matching files appear + run_search(&mut app, Instant::now()); + + let mention = app.mention.as_ref().expect("mention should be active"); + assert!(mention.candidates.iter().any(|candidate| candidate.rel_path == "root.rs")); + assert!( + mention.candidates.iter().any(|candidate| candidate.rel_path == "src/nested/deep.rs") + ); + assert!(matches!(mention.search_status, MentionSearchStatus::Ready)); + } + + #[test] + fn query_change_refilters_from_cache_without_restarting_walk() { + let (mut app, _tmp) = + app_with_temp_files(&["root.rs", "src/nested/needle.rs", "src/nested/other.rs"]); + app.input.set_text("@rs"); + let _ = app.input.set_cursor(0, 3); + + activate(&mut app); + run_search(&mut app, Instant::now()); + assert!(app.mention.as_ref().is_some_and(|mention| { + mention.candidates.iter().any(|candidate| candidate.rel_path == "root.rs") + })); + + // Change query — should refilter from cache, not restart the walker + app.input.set_text("@needle"); + let _ = app.input.set_cursor(0, "@needle".chars().count()); + update_query(&mut app); + + // The walker cache should still be present (not restarted) + let mention = app.mention.as_ref().expect("mention should remain active"); + // Since walker finished and cache has all entries, refilter is instant + assert_eq!(mention.candidates.len(), 1); + assert_eq!(mention.candidates[0].rel_path, "src/nested/needle.rs"); + } + + #[test] + fn basename_prefix_ranks_ahead_of_shallow_path_substring() { + let mut candidates = vec![ + FileCandidate { + rel_path: "docs/guide-rs.txt".to_owned(), + rel_path_lower: "docs/guide-rs.txt".to_owned(), + basename_lower: "guide-rs.txt".to_owned(), + depth: 1, + modified: SystemTime::UNIX_EPOCH, + is_dir: false, + }, + FileCandidate { + rel_path: "src/rs-helper.rs".to_owned(), + rel_path_lower: "src/rs-helper.rs".to_owned(), + basename_lower: "rs-helper.rs".to_owned(), + depth: 1, + modified: SystemTime::UNIX_EPOCH, + is_dir: false, + }, + ]; + + rank_and_truncate_candidates(&mut candidates, "rs"); + + assert_eq!(candidates[0].rel_path, "src/rs-helper.rs"); + } +} diff --git a/claude-code-rust/src/app/mod.rs b/claude-code-rust/src/app/mod.rs new file mode 100644 index 0000000..459e92b --- /dev/null +++ b/claude-code-rust/src/app/mod.rs @@ -0,0 +1,784 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) mod auth; +mod cache_policy; +pub(crate) mod config; +mod connect; +mod dialog; +mod events; +mod focus; +mod inline_interactions; +pub(crate) mod input; +mod input_submit; +mod keys; +pub(crate) mod mention; +mod notify; +pub(crate) mod paste_burst; +mod permissions; +pub(crate) mod plugins; +mod questions; +mod selection; +mod service_status_check; +pub(crate) mod slash; +mod state; +pub(crate) mod subagent; +mod terminal; +mod todos; +mod trust; +mod update_check; +pub(crate) mod usage; +mod view; + +// Re-export all public types so `crate::app::App`, `crate::app::BlockCache`, etc. still work. +pub use cache_policy::{ + CacheSplitPolicy, DEFAULT_CACHE_SPLIT_HARD_LIMIT_BYTES, DEFAULT_CACHE_SPLIT_SOFT_LIMIT_BYTES, + DEFAULT_TOOL_PREVIEW_LIMIT_BYTES, TextSplitDecision, TextSplitKind, default_cache_split_policy, + find_text_split, find_text_split_index, +}; +pub use config::{ConfigState, ConfigTab}; +pub use connect::{create_app, start_connection}; +pub use events::{handle_client_event, handle_terminal_event}; +pub use focus::{FocusManager, FocusOwner, FocusTarget}; +pub use input::InputState; +pub(crate) use selection::normalize_selection; +pub use service_status_check::start_service_status_check; +pub(crate) use state::MarkdownRenderKey; +pub(crate) use state::cache_metrics; +pub use state::{ + App, AppStatus, BlockCache, CacheMetrics, CancelOrigin, ChatMessage, ChatViewport, ExtraUsage, + HelpView, IncrementalMarkdown, InlinePermission, InlineQuestion, InvalidationLevel, + LayoutInvalidation, LoginHint, McpState, MessageBlock, MessageRole, MessageUsage, ModeInfo, + ModeState, PasteSessionState, PendingCommandAck, RecentSessionInfo, SelectionKind, + SelectionPoint, SelectionState, SessionUsageState, SystemSeverity, TerminalSnapshotMode, + TextBlock, TextBlockSpacing, TodoItem, TodoStatus, ToolCallInfo, ToolCallScope, UsageSnapshot, + UsageSourceKind, UsageSourceMode, UsageState, UsageWindow, WelcomeBlock, is_execute_tool_name, +}; +pub use trust::TrustSelection; +pub use update_check::start_update_check; +pub use view::ActiveView; + +use crate::agent::model; +use crossterm::event::{ + EventStream, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, +}; +use futures::{FutureExt as _, StreamExt}; +use std::time::{Duration, Instant}; + +const SPINNER_FRAME_INTERVAL_NORMAL: Duration = Duration::from_millis(30); +const SPINNER_FRAME_INTERVAL_REDUCED: Duration = Duration::from_millis(120); + +// --------------------------------------------------------------------------- +// Terminal suspend / resume helpers (reused by /login, /logout) +// --------------------------------------------------------------------------- + +/// Disable raw mode and crossterm features so a child process can own the +/// terminal (e.g. `claude auth login` which opens a browser flow). +pub(crate) fn suspend_terminal() { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::DisableBracketedPaste, + crossterm::event::DisableMouseCapture, + crossterm::event::DisableFocusChange, + PopKeyboardEnhancementFlags + ); + let _ = crossterm::terminal::disable_raw_mode(); +} + +/// Re-enable raw mode and crossterm features after a child process finishes. +pub(crate) fn resume_terminal() { + let _ = crossterm::terminal::enable_raw_mode(); + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::EnableBracketedPaste, + crossterm::event::EnableMouseCapture, + crossterm::event::EnableFocusChange, + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS + ) + ); +} + +// --------------------------------------------------------------------------- +// TUI event loop +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_lines, clippy::cast_precision_loss)] +pub async fn run_tui(app: &mut App) -> anyhow::Result<()> { + let mut terminal = ratatui::init(); + let mut os_shutdown = Box::pin(wait_for_shutdown_signal()); + + // Enable bracketed paste, mouse capture, and enhanced keyboard protocol + resume_terminal(); + + let mut events = EventStream::new(); + let tick_duration = Duration::from_millis(16); + let mut last_render = Instant::now(); + + loop { + start_connection(app); + + // Phase 1: wait for at least one event or the next frame tick + let time_to_next = tick_duration.saturating_sub(last_render.elapsed()); + tokio::select! { + Some(Ok(event)) = events.next() => { + events::handle_terminal_event(app, event); + } + Some(event) = app.event_rx.recv() => { + events::handle_client_event(app, event); + } + shutdown = &mut os_shutdown => { + if let Err(err) = shutdown { + tracing::warn!(%err, "OS shutdown signal listener failed"); + } + app.should_quit = true; + } + () = tokio::time::sleep(time_to_next) => {} + } + + // Phase 2: drain all remaining queued events (non-blocking) + loop { + // Try terminal events first (keeps typing responsive) + if let Some(Some(Ok(event))) = events.next().now_or_never() { + events::handle_terminal_event(app, event); + continue; + } + // Then client events + match app.event_rx.try_recv() { + Ok(event) => { + events::handle_client_event(app, event); + } + Err(_) => break, + } + } + + // Tick the burst detector: flush any held/buffered content that + // has timed out. EmitChar re-inserts a single held character; + // EmitPaste feeds the accumulated burst into the paste queue. + if app.active_view == ActiveView::Chat + && let Some(action) = app.paste_burst.tick(Instant::now()) + { + match action { + paste_burst::FlushAction::EmitChar(ch) => { + let _ = app.input.textarea_insert_char(ch); + } + paste_burst::FlushAction::EmitPaste(text) => { + app.queue_paste_text(&text); + } + } + } + + // Merge and process `Event::Paste` chunks as one paste action. + if app.active_view == ActiveView::Chat && !app.pending_paste_text.is_empty() { + finalize_pending_paste_event(app); + } + + mention::tick(app, Instant::now()); + + // Deferred submit: if Enter was pressed and no paste payload arrived + // in this drain cycle, restore the exact pre-submit snapshot and + // submit that unchanged draft. + if app.active_view == ActiveView::Chat && app.pending_submit.is_some() { + finalize_deferred_submit(app); + } + + if app.should_quit { + break; + } + + // Phase 3: render once (only when something changed) + let is_animating = matches!( + app.status, + AppStatus::Connecting + | AppStatus::CommandPending + | AppStatus::Thinking + | AppStatus::Running + ) || app.is_compacting; + if is_animating { + advance_spinner_frame(app, Instant::now()); + app.needs_redraw = true; + } else { + app.spinner_last_advance_at = None; + } + // Smooth scroll still settling + let scroll_delta = (app.viewport.scroll_target as f32 - app.viewport.scroll_pos).abs(); + if scroll_delta >= 0.01 { + app.needs_redraw = true; + } + if terminal::update_terminal_outputs(app) { + app.needs_redraw = true; + } + if app.force_redraw { + terminal.clear()?; + app.force_redraw = false; + app.needs_redraw = true; + } + if app.needs_redraw { + if let Some(ref mut perf) = app.perf { + perf.next_frame(); + } + if app.perf.is_some() { + app.mark_frame_presented(Instant::now()); + } + #[allow(clippy::drop_non_drop)] + { + let timer = app.perf.as_ref().map(|p| p.start("frame_total")); + let draw_timer = app.perf.as_ref().map(|p| p.start("frame::terminal_draw")); + terminal.draw(|f| crate::ui::render(f, app))?; + drop(draw_timer); + drop(timer); + } + app.needs_redraw = false; + last_render = Instant::now(); + } + } + + // --- Graceful shutdown --- + + // Dismiss all pending inline permissions (reject via last option) + for tool_id in std::mem::take(&mut app.pending_interaction_ids) { + if let Some((mi, bi)) = app.tool_call_index.get(&tool_id).copied() + && let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + { + let tc = tc.as_mut(); + if let Some(pending) = tc.pending_permission.take() + && let Some(last_opt) = pending.options.last() + { + let _ = pending.response_tx.send(model::RequestPermissionResponse::new( + model::RequestPermissionOutcome::Selected( + model::SelectedPermissionOutcome::new(last_opt.option_id.clone()), + ), + )); + } + if let Some(pending) = tc.pending_question.take() { + let _ = pending.response_tx.send(model::RequestQuestionResponse::new( + model::RequestQuestionOutcome::Cancelled, + )); + } + } + } + + // Cancel any active turn and give the adapter a moment to clean up + if matches!(app.status, AppStatus::Thinking | AppStatus::Running) + && let Some(ref conn) = app.conn + && let Some(sid) = app.session_id.clone() + { + let _ = conn.cancel(sid.to_string()); + } + + // Restore terminal + suspend_terminal(); + ratatui::restore(); + + Ok(()) +} + +fn advance_spinner_frame(app: &mut App, now: Instant) { + let interval = if app.config.prefers_reduced_motion_effective() { + SPINNER_FRAME_INTERVAL_REDUCED + } else { + SPINNER_FRAME_INTERVAL_NORMAL + }; + + match app.spinner_last_advance_at { + Some(last_advance) if now.duration_since(last_advance) < interval => {} + Some(_) | None => { + app.spinner_frame = app.spinner_frame.wrapping_add(1); + app.spinner_last_advance_at = Some(now); + } + } +} + +async fn wait_for_shutdown_signal() -> std::io::Result<()> { + #[cfg(unix)] + { + let mut sigterm = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + tokio::select! { + sigint = tokio::signal::ctrl_c() => { + sigint?; + } + _ = sigterm.recv() => {} + } + Ok(()) + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await + } +} + +/// Finalize queued `Event::Paste` chunks for this drain cycle. +fn finalize_pending_paste_event(app: &mut App) { + let pasted = std::mem::take(&mut app.pending_paste_text); + if pasted.is_empty() { + return; + } + tracing::debug!( + text = %debug_paste_text(&pasted), + len = pasted.chars().count(), + cursor_row = app.input.cursor_row(), + cursor_col = app.input.cursor_col(), + "paste_finalize: begin" + ); + + let session = app.pending_paste_session.take().unwrap_or_else(|| { + let id = app.next_paste_session_id; + app.next_paste_session_id = app.next_paste_session_id.saturating_add(1); + state::PasteSessionState { + id, + start: SelectionPoint { row: app.input.cursor_row(), col: app.input.cursor_col() }, + placeholder_index: None, + } + }); + tracing::debug!( + session_id = session.id, + start_row = session.start.row, + start_col = session.start.col, + placeholder_index = ?session.placeholder_index, + "paste_finalize: session" + ); + + if session.placeholder_index.is_none() { + let end = SelectionPoint { row: app.input.cursor_row(), col: app.input.cursor_col() }; + tracing::debug!( + end_row = end.row, + end_col = end.col, + "paste_finalize: strip leaked inline range" + ); + strip_input_range(app, session.start, end); + } + + let appended = session + .placeholder_index + .and_then(|session_idx| { + let current_line = app.input.lines().get(app.input.cursor_row())?; + let current_idx = + input::parse_paste_placeholder_before_cursor(current_line, app.input.cursor_col())?; + (current_idx == session_idx).then_some(()) + }) + .is_some() + && app.input.append_to_active_paste_block(&pasted); + if appended { + app.active_paste_session = Some(session); + app.needs_redraw = true; + tracing::debug!("paste_finalize: appended to active placeholder"); + return; + } + + let char_count = input::count_text_chars(&pasted); + if char_count > input::PASTE_PLACEHOLDER_CHAR_THRESHOLD { + app.input.insert_paste_block(&pasted); + let idx = app.input.lines().get(app.input.cursor_row()).and_then(|line| { + input::parse_paste_placeholder_before_cursor(line, app.input.cursor_col()) + }); + app.active_paste_session = + Some(state::PasteSessionState { placeholder_index: idx, ..session }); + tracing::debug!(char_count, placeholder_index = ?idx, "paste_finalize: inserted placeholder"); + } else { + app.input.insert_str(&pasted); + app.active_paste_session = None; + tracing::debug!( + char_count, + lines = app.input.lines().len(), + "paste_finalize: inserted inline text" + ); + } + app.needs_redraw = true; +} + +fn cursor_gt(a: SelectionPoint, b: SelectionPoint) -> bool { + a.row > b.row || (a.row == b.row && a.col > b.col) +} + +fn cursor_to_byte_offset(lines: &[String], cursor: SelectionPoint) -> Option { + let line = lines.get(cursor.row)?; + let mut offset = 0usize; + for prior in &lines[..cursor.row] { + offset = offset.saturating_add(prior.len().saturating_add(1)); + } + Some(offset.saturating_add(char_to_byte_index(line, cursor.col))) +} + +fn char_to_byte_index(text: &str, char_idx: usize) -> usize { + text.char_indices().nth(char_idx).map_or(text.len(), |(i, _)| i) +} + +fn byte_offset_to_cursor(text: &str, byte_offset: usize) -> SelectionPoint { + let mut row = 0usize; + let mut col = 0usize; + let mut seen = 0usize; + for ch in text.chars() { + let ch_len = ch.len_utf8(); + if seen + ch_len > byte_offset { + break; + } + seen += ch_len; + if ch == '\n' { + row += 1; + col = 0; + } else { + col += 1; + } + } + SelectionPoint { row, col } +} + +fn apply_merged_input_snapshot(app: &mut App, merged: &str, cursor_offset: usize) { + let mut lines: Vec = merged.split('\n').map(ToOwned::to_owned).collect(); + if lines.is_empty() { + lines.push(String::new()); + } + let mut cursor = byte_offset_to_cursor(merged, cursor_offset.min(merged.len())); + if cursor.row >= lines.len() { + cursor.row = lines.len().saturating_sub(1); + cursor.col = lines[cursor.row].chars().count(); + } else { + cursor.col = cursor.col.min(lines[cursor.row].chars().count()); + } + + app.input.replace_lines_and_cursor(lines, cursor.row, cursor.col); +} + +fn debug_paste_text(text: &str) -> String { + const MAX_CHARS: usize = 60; + let mut out = String::new(); + let mut iter = text.chars(); + for _ in 0..MAX_CHARS { + let Some(ch) = iter.next() else { + return out; + }; + out.extend(ch.escape_default()); + } + if iter.next().is_some() { + out.push_str("..."); + } + out +} + +fn strip_input_range(app: &mut App, start: SelectionPoint, end: SelectionPoint) { + if cursor_gt(start, end) || start == end { + return; + } + let Some(start_offset) = cursor_to_byte_offset(app.input.lines(), start) else { + return; + }; + let Some(end_offset) = cursor_to_byte_offset(app.input.lines(), end) else { + return; + }; + if start_offset >= end_offset { + return; + } + let raw = app.input.lines().join("\n"); + if end_offset > raw.len() { + return; + } + let mut merged = String::with_capacity(raw.len().saturating_sub(end_offset - start_offset)); + merged.push_str(&raw[..start_offset]); + merged.push_str(&raw[end_offset..]); + apply_merged_input_snapshot(app, &merged, start_offset); +} + +/// Finalize a deferred Enter by restoring the exact pre-submit input snapshot +/// and submitting that original draft text. +fn finalize_deferred_submit(app: &mut App) { + let Some(snapshot) = app.pending_submit.take() else { + return; + }; + app.input.restore_snapshot(snapshot); + input_submit::submit_input(app); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::model; + use crate::agent::wire::BridgeCommand; + use crate::app::{MessageBlock, MessageRole}; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + + fn app_with_connection() + -> (App, tokio::sync::mpsc::UnboundedReceiver) { + let mut app = App::test_default(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(std::rc::Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some(model::SessionId::new("session-1")); + (app, rx) + } + + #[test] + fn pending_paste_chunks_are_merged_before_threshold_check() { + let mut app = App::test_default(); + let first = "a".repeat(700); + let second = "b".repeat(401); + events::handle_terminal_event(&mut app, Event::Paste(first.clone())); + events::handle_terminal_event(&mut app, Event::Paste(second.clone())); + + // Not applied until post-drain finalization. + assert!(app.input.is_empty()); + assert!(!app.pending_paste_text.is_empty()); + + finalize_pending_paste_event(&mut app); + + assert_eq!(app.input.lines(), vec!["[Pasted Text 1 - 1101 chars]"]); + assert_eq!(app.input.text(), format!("{first}{second}")); + } + + #[test] + fn pending_paste_chunk_appends_to_same_session_placeholder() { + let mut app = App::test_default(); + app.input.insert_paste_block("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk"); + app.active_paste_session = Some(state::PasteSessionState { + id: 7, + start: SelectionPoint { row: 0, col: 0 }, + placeholder_index: Some(0), + }); + app.pending_paste_session = app.active_paste_session; + app.pending_paste_text = "\nl\nm".to_owned(); + + finalize_pending_paste_event(&mut app); + + assert_eq!(app.input.lines(), vec!["[Pasted Text 1 - 25 chars]"]); + assert_eq!(app.input.text(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm"); + } + + #[test] + fn pending_paste_exact_1000_chars_stays_inline() { + let mut app = App::test_default(); + app.pending_paste_text = "x".repeat(1000); + + finalize_pending_paste_event(&mut app); + + assert_eq!(app.input.lines(), vec!["x".repeat(1000)]); + } + + #[test] + fn pending_paste_finalization_marks_redraw() { + let mut app = App::test_default(); + app.needs_redraw = false; + app.pending_paste_text = "hello\nworld".to_owned(); + + finalize_pending_paste_event(&mut app); + + assert!(app.needs_redraw); + assert_eq!(app.input.lines(), vec!["hello", "world"]); + } + + #[test] + fn suppressed_enter_preserves_multiline_inline_paste() { + let mut app = App::test_default(); + let t0 = Instant::now(); + + assert_eq!(app.paste_burst.on_char('a', t0), paste_burst::CharAction::Passthrough('a')); + let _ = app.input.textarea_insert_char('a'); + assert_eq!( + app.paste_burst.on_char('b', t0 + Duration::from_millis(2)), + paste_burst::CharAction::Consumed + ); + assert_eq!( + app.paste_burst.on_char('c', t0 + Duration::from_millis(4)), + paste_burst::CharAction::RetroCapture(1) + ); + let _ = app.input.textarea_delete_char_before(); + + let t_flush = t0 + Duration::from_millis(200); + assert_eq!( + app.paste_burst.tick(t_flush), + Some(paste_burst::FlushAction::EmitPaste("abc".to_owned())) + ); + app.queue_paste_text("abc"); + finalize_pending_paste_event(&mut app); + assert_eq!(app.input.text(), "abc"); + + let t_enter = t_flush + Duration::from_millis(10); + assert!(app.paste_burst.on_enter(t_enter)); + assert_eq!( + app.paste_burst.on_char('d', t_enter + Duration::from_millis(1)), + paste_burst::CharAction::Consumed + ); + assert_eq!( + app.paste_burst.on_char('e', t_enter + Duration::from_millis(2)), + paste_burst::CharAction::Consumed + ); + assert_eq!( + app.paste_burst.on_char('f', t_enter + Duration::from_millis(3)), + paste_burst::CharAction::Consumed + ); + + let t_second_flush = t_enter + Duration::from_millis(200); + assert_eq!( + app.paste_burst.tick(t_second_flush), + Some(paste_burst::FlushAction::EmitPaste("\ndef".to_owned())) + ); + app.queue_paste_text("\ndef"); + finalize_pending_paste_event(&mut app); + + assert_eq!(app.input.lines(), vec!["abc", "def"]); + assert_eq!(app.input.text(), "abc\ndef"); + } + + #[test] + fn pending_paste_1001_chars_becomes_placeholder() { + let mut app = App::test_default(); + app.pending_paste_text = "x".repeat(1001); + + finalize_pending_paste_event(&mut app); + + assert_eq!(app.input.lines(), vec!["[Pasted Text 1 - 1001 chars]"]); + assert_eq!(app.input.text(), "x".repeat(1001)); + } + + #[test] + fn pending_paste_session_isolation_prevents_unintended_append() { + let mut app = App::test_default(); + app.pending_paste_text = "a".repeat(1001); + finalize_pending_paste_event(&mut app); + events::handle_terminal_event( + &mut app, + Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('v'), + crossterm::event::KeyModifiers::CONTROL, + )), + ); + + app.pending_paste_text = "b".repeat(1001); + finalize_pending_paste_event(&mut app); + + assert_eq!( + app.input.lines(), + vec!["[Pasted Text 1 - 1001 chars][Pasted Text 2 - 1001 chars]"] + ); + assert_eq!(app.input.text(), format!("{}{}", "a".repeat(1001), "b".repeat(1001))); + } + + #[test] + fn plain_enter_preserves_single_line_draft_before_submit() { + let (mut app, mut rx) = app_with_connection(); + app.input.set_text("hello world"); + let _ = app.input.set_cursor(0, "hello".chars().count()); + + events::handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ); + + assert_eq!(app.input.text(), "hello world"); + assert_eq!(app.input.cursor(), (0, "hello".chars().count())); + assert!(app.pending_submit.is_some()); + + finalize_deferred_submit(&mut app); + + assert!(app.pending_submit.is_none()); + assert!(app.input.text().is_empty()); + assert_eq!(app.messages.len(), 2); + assert!(matches!(app.messages[0].role, MessageRole::User)); + assert!(matches!( + app.messages[0].blocks.as_slice(), + [MessageBlock::Text(block)] if block.text == "hello world" + )); + let envelope = rx.try_recv().expect("prompt command should be sent"); + assert!(matches!( + envelope.command, + BridgeCommand::Prompt { session_id, .. } if session_id == "session-1" + )); + } + + #[test] + fn plain_enter_preserves_multiline_draft_with_mid_buffer_cursor() { + let (mut app, mut rx) = app_with_connection(); + app.input.set_text("alpha beta\ngamma"); + let _ = app.input.set_cursor(0, "alpha".chars().count()); + + events::handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ); + + assert_eq!(app.input.text(), "alpha beta\ngamma"); + assert_eq!(app.input.cursor(), (0, "alpha".chars().count())); + assert!(app.pending_submit.is_some()); + + finalize_deferred_submit(&mut app); + + assert!(app.pending_submit.is_none()); + assert!(matches!( + app.messages[0].blocks.as_slice(), + [MessageBlock::Text(block)] if block.text == "alpha beta\ngamma" + )); + let envelope = rx.try_recv().expect("prompt command should be sent"); + assert!(matches!( + envelope.command, + BridgeCommand::Prompt { session_id, .. } if session_id == "session-1" + )); + } + + #[test] + fn paste_event_cancels_deferred_submit_snapshot() { + let mut app = App::test_default(); + app.input.set_text("draft"); + + events::handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ); + assert!(app.pending_submit.is_some()); + + events::handle_terminal_event(&mut app, Event::Paste("pasted".into())); + + assert!(app.pending_submit.is_none()); + assert_eq!(app.pending_paste_text, "pasted"); + assert_eq!(app.input.text(), "draft"); + } + + #[test] + fn esc_cancels_deferred_submit_snapshot_before_finalize() { + let (mut app, mut rx) = app_with_connection(); + app.input.set_text("draft"); + + events::handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ); + assert!(app.pending_submit.is_some()); + + events::handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ); + + assert!(app.pending_submit.is_none()); + finalize_deferred_submit(&mut app); + assert_eq!(app.input.text(), "draft"); + assert!(app.messages.is_empty()); + assert!(rx.try_recv().is_err(), "Esc should prevent deferred submit dispatch"); + } + + #[test] + fn spinner_advances_less_frequently_when_reduced_motion_enabled() { + let mut app = App::test_default(); + let base = Instant::now(); + + advance_spinner_frame(&mut app, base); + assert_eq!(app.spinner_frame, 1); + advance_spinner_frame(&mut app, base + Duration::from_millis(40)); + assert_eq!(app.spinner_frame, 2); + + crate::app::config::store::set_prefers_reduced_motion( + &mut app.config.committed_local_settings_document, + true, + ); + app.spinner_last_advance_at = None; + app.spinner_frame = 0; + + advance_spinner_frame(&mut app, base); + assert_eq!(app.spinner_frame, 1); + advance_spinner_frame(&mut app, base + Duration::from_millis(95)); + assert_eq!(app.spinner_frame, 1); + advance_spinner_frame(&mut app, base + Duration::from_millis(121)); + assert_eq!(app.spinner_frame, 2); + } +} diff --git a/claude-code-rust/src/app/notify.rs b/claude-code-rust/src/app/notify.rs new file mode 100644 index 0000000..f506491 --- /dev/null +++ b/claude-code-rust/src/app/notify.rs @@ -0,0 +1,384 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::config::PreferredNotifChannel; +use std::borrow::Cow; + +/// Events that can trigger a user notification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotifyEvent { + /// A tool call requires explicit user approval. + PermissionRequired, + /// `AskUserQuestion` is waiting for structured input. + QuestionRequired, + /// The agent finished its turn. + TurnComplete, +} + +/// Central notification manager. +/// +/// Tracks whether the terminal window is focused (via crossterm +/// `FocusGained`/`FocusLost` events backed by DECSET 1004) and dispatches +/// notifications only when the window is **not** focused. +/// +/// Two notification layers fire in parallel: +/// 1. **Terminal bell** (`BEL \x07`) -- causes a taskbar flash / dock bounce +/// on virtually every terminal emulator. +/// 2. **Desktop notification** via `notify-rust` -- OS-native toast popup +/// (Windows Toast, macOS Notification Center, Linux freedesktop D-Bus). +/// Spawned on a background thread so it never blocks the TUI event loop. +/// Silently ignored when the notification backend is unavailable (e.g. SSH). +#[derive(Debug)] +pub struct NotificationManager { + terminal_focused: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NotificationPlan { + ring_bell: bool, + send_desktop: bool, + osc9_text: Option<&'static str>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct TerminalCapabilities { + osc9_notifications: bool, +} + +impl NotificationManager { + #[must_use] + pub const fn new() -> Self { + // Default to `true` (focused) so that terminals which do not support + // DECSET 1004 never fire spurious notifications. + Self { terminal_focused: true } + } + + /// Call when the terminal emits a `FocusGained` event. + pub fn on_focus_gained(&mut self) { + self.terminal_focused = true; + } + + /// Call when the terminal emits a `FocusLost` event. + pub fn on_focus_lost(&mut self) { + self.terminal_focused = false; + } + + /// Whether the terminal window currently has OS focus. + #[must_use] + pub const fn is_focused(&self) -> bool { + self.terminal_focused + } + + /// Send a notification if the terminal is not focused. + /// + /// This is the single entry-point that all event handlers should call. + /// It is intentionally cheap when focused (just a bool check). + pub fn notify(&self, channel: PreferredNotifChannel, event: NotifyEvent) { + if self.terminal_focused { + return; + } + let plan = notification_plan(channel, detect_terminal_capabilities(), event); + if let Some(text) = plan.osc9_text { + send_osc9_notification(text); + } + if plan.ring_bell { + ring_bell(); + } + if plan.send_desktop { + send_desktop_notification(event); + } + } +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/// Write the ASCII BEL character to stdout, causing a taskbar flash / dock +/// bounce in most terminal emulators. +fn ring_bell() { + use std::io::Write; + let _ = std::io::stdout().write_all(b"\x07"); + let _ = std::io::stdout().flush(); +} + +/// Spawn a background thread that sends an OS-native desktop notification. +/// +/// Runs on `std::thread::spawn` rather than tokio because `notify-rust`'s +/// `show()` may block on a D-Bus round-trip (Linux) or COM call (Windows). +/// Errors are silently discarded -- the bell is the reliable fallback. +fn send_desktop_notification(event: NotifyEvent) { + let (summary, body) = match event { + NotifyEvent::PermissionRequired => { + ("Claude Code", "Permission required -- waiting for your approval") + } + NotifyEvent::QuestionRequired => { + ("Claude Code", "Question required -- waiting for your input") + } + NotifyEvent::TurnComplete => ("Claude Code", "Turn complete"), + }; + std::thread::spawn(move || { + let _ = notify_rust::Notification::new().summary(summary).body(body).show(); + }); +} + +fn send_osc9_notification(message: &str) { + use std::io::Write; + + let sequence = osc9_escape_sequence(message); + let _ = std::io::stdout().write_all(sequence.as_bytes()); + let _ = std::io::stdout().flush(); +} + +fn notification_plan( + channel: PreferredNotifChannel, + capabilities: TerminalCapabilities, + event: NotifyEvent, +) -> NotificationPlan { + let osc9_text = capabilities.osc9_notifications.then(|| notification_text(event)); + match channel { + PreferredNotifChannel::NotificationsDisabled => { + NotificationPlan { ring_bell: false, send_desktop: false, osc9_text: None } + } + PreferredNotifChannel::TerminalBell => { + NotificationPlan { ring_bell: true, send_desktop: false, osc9_text: None } + } + // "Auto / iTerm2" replaced the original always-bell-plus-desktop behavior. + // Preserve that reliable fallback whenever OSC 9 is unavailable. + PreferredNotifChannel::Iterm2 => NotificationPlan { + ring_bell: osc9_text.is_none(), + send_desktop: osc9_text.is_none(), + osc9_text, + }, + PreferredNotifChannel::Ghostty => { + NotificationPlan { ring_bell: false, send_desktop: osc9_text.is_none(), osc9_text } + } + PreferredNotifChannel::Iterm2WithBell => { + NotificationPlan { ring_bell: true, send_desktop: osc9_text.is_none(), osc9_text } + } + } +} + +fn detect_terminal_capabilities() -> TerminalCapabilities { + terminal_capabilities_from_env( + std::env::vars_os() + .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))), + ) +} + +fn terminal_capabilities_from_env(vars: I) -> TerminalCapabilities +where + I: IntoIterator, +{ + let mut term_program = None::; + let mut iterm_session = false; + + for (key, value) in vars { + match key.as_str() { + "TERM_PROGRAM" => term_program = Some(value), + "ITERM_SESSION_ID" if !value.is_empty() => iterm_session = true, + _ => {} + } + } + + let osc9_notifications = + matches!(term_program.as_deref(), Some("iTerm.app" | "ghostty")) || iterm_session; + TerminalCapabilities { osc9_notifications } +} + +const fn notification_text(event: NotifyEvent) -> &'static str { + match event { + NotifyEvent::PermissionRequired => "Claude Code: Permission required", + NotifyEvent::QuestionRequired => "Claude Code: Question required", + NotifyEvent::TurnComplete => "Claude Code: Turn complete", + } +} + +fn osc9_escape_sequence(message: &str) -> Cow<'_, str> { + let sanitized = sanitize_osc9_message(message); + let mut sequence = String::with_capacity(sanitized.len() + 8); + sequence.push('\u{1b}'); + sequence.push_str("]9;"); + sequence.push_str(&sanitized); + sequence.push('\u{1b}'); + sequence.push('\\'); + Cow::Owned(sequence) +} + +fn sanitize_osc9_message(message: &str) -> String { + let mut sanitized = String::with_capacity(message.len()); + for ch in message.chars() { + match ch { + '\u{07}' | '\u{1b}' | '\u{9c}' => {} + '\r' | '\n' => sanitized.push(' '), + _ => sanitized.push(ch), + } + } + sanitized +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_focused() { + let mgr = NotificationManager::new(); + assert!(mgr.is_focused(), "should default to focused to suppress spurious notifications"); + } + + #[test] + fn focus_lost_sets_unfocused() { + let mut mgr = NotificationManager::new(); + mgr.on_focus_lost(); + assert!(!mgr.is_focused()); + } + + #[test] + fn focus_gained_restores_focused() { + let mut mgr = NotificationManager::new(); + mgr.on_focus_lost(); + mgr.on_focus_gained(); + assert!(mgr.is_focused()); + } + + #[test] + fn disabled_notifications_plan_is_silent() { + assert_eq!( + notification_plan( + PreferredNotifChannel::NotificationsDisabled, + TerminalCapabilities { osc9_notifications: true }, + NotifyEvent::TurnComplete, + ), + NotificationPlan { ring_bell: false, send_desktop: false, osc9_text: None } + ); + } + + #[test] + fn terminal_bell_plan_skips_desktop_notification() { + assert_eq!( + notification_plan( + PreferredNotifChannel::TerminalBell, + TerminalCapabilities { osc9_notifications: true }, + NotifyEvent::TurnComplete, + ), + NotificationPlan { ring_bell: true, send_desktop: false, osc9_text: None } + ); + } + + #[test] + fn iterm2_uses_osc9_when_supported() { + assert_eq!( + notification_plan( + PreferredNotifChannel::Iterm2, + TerminalCapabilities { osc9_notifications: true }, + NotifyEvent::TurnComplete, + ), + NotificationPlan { + ring_bell: false, + send_desktop: false, + osc9_text: Some("Claude Code: Turn complete"), + } + ); + } + + #[test] + fn iterm2_auto_preserves_bell_and_desktop_fallback_when_osc9_is_unavailable() { + assert_eq!( + notification_plan( + PreferredNotifChannel::Iterm2, + TerminalCapabilities { osc9_notifications: false }, + NotifyEvent::TurnComplete, + ), + NotificationPlan { ring_bell: true, send_desktop: true, osc9_text: None } + ); + } + + #[test] + fn iterm2_with_bell_uses_osc9_and_bell_when_supported() { + assert_eq!( + notification_plan( + PreferredNotifChannel::Iterm2WithBell, + TerminalCapabilities { osc9_notifications: true }, + NotifyEvent::PermissionRequired, + ), + NotificationPlan { + ring_bell: true, + send_desktop: false, + osc9_text: Some("Claude Code: Permission required"), + } + ); + } + + #[test] + fn iterm2_with_bell_falls_back_to_desktop_and_bell() { + assert_eq!( + notification_plan( + PreferredNotifChannel::Iterm2WithBell, + TerminalCapabilities { osc9_notifications: false }, + NotifyEvent::PermissionRequired, + ), + NotificationPlan { ring_bell: true, send_desktop: true, osc9_text: None } + ); + } + + #[test] + fn ghostty_uses_osc9_when_supported() { + assert_eq!( + notification_plan( + PreferredNotifChannel::Ghostty, + TerminalCapabilities { osc9_notifications: true }, + NotifyEvent::TurnComplete, + ), + NotificationPlan { + ring_bell: false, + send_desktop: false, + osc9_text: Some("Claude Code: Turn complete"), + } + ); + } + + #[test] + fn detects_iterm2_via_term_program() { + let capabilities = + terminal_capabilities_from_env([("TERM_PROGRAM".to_owned(), "iTerm.app".to_owned())]); + + assert!(capabilities.osc9_notifications); + } + + #[test] + fn detects_iterm2_via_session_id() { + let capabilities = + terminal_capabilities_from_env([("ITERM_SESSION_ID".to_owned(), "w0t1p0".to_owned())]); + + assert!(capabilities.osc9_notifications); + } + + #[test] + fn detects_ghostty_via_term_program() { + let capabilities = + terminal_capabilities_from_env([("TERM_PROGRAM".to_owned(), "ghostty".to_owned())]); + + assert!(capabilities.osc9_notifications); + } + + #[test] + fn unsupported_term_does_not_advertise_osc9() { + let capabilities = + terminal_capabilities_from_env([("TERM_PROGRAM".to_owned(), "wezterm".to_owned())]); + + assert!(!capabilities.osc9_notifications); + } + + #[test] + fn osc9_sequence_uses_st_terminator_and_sanitizes_message() { + assert_eq!( + osc9_escape_sequence("hello\n\u{1b}world\u{07}").as_ref(), + "\u{1b}]9;hello world\u{1b}\\" + ); + } +} diff --git a/claude-code-rust/src/app/paste_burst.rs b/claude-code-rust/src/app/paste_burst.rs new file mode 100644 index 0000000..031238d --- /dev/null +++ b/claude-code-rust/src/app/paste_burst.rs @@ -0,0 +1,677 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Timing-based paste burst detection for terminals that don't reliably +//! surface bracketed paste events (notably Windows Terminal with crossterm). +//! +//! When a user pastes text, the terminal may deliver each character as a +//! separate `Event::Key(Char(_))` at machine speed. This module detects +//! such rapid character streams and buffers them into a single paste payload, +//! which is then routed through [`super::App::queue_paste_text`] like a +//! normal `Event::Paste`. +//! +//! Design inspired by: +//! - Codex CLI (`paste_burst.rs`): hold-first strategy, platform-tuned +//! intervals, enter suppression window, retro-capture heuristics. +//! - Gemini CLI (`KeypressContext.tsx`): fast-return buffering at 30ms. +//! +//! # State machine +//! +//! ```text +//! Idle ──(fast char)──> Pending ──(2nd fast char)──> Buffering ──(idle)──> Flush +//! │ │ +//! (timeout) (enter suppression +//! v window active) +//! Emit held char +//! ``` + +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Platform-tuned timing constants +// --------------------------------------------------------------------------- + +/// Maximum gap between consecutive characters to be considered part of the +/// same paste burst. Characters arriving faster than this are machine-speed +/// (paste), not human typing (~100-200ms per keystroke). +#[cfg(not(windows))] +const CHAR_INTERVAL: Duration = Duration::from_millis(8); + +#[cfg(windows)] +const CHAR_INTERVAL: Duration = Duration::from_millis(30); + +/// How long to wait after the last buffered character before flushing the +/// burst as a completed paste. Slightly longer on Windows where terminal +/// I/O adds latency between pasted characters. +#[cfg(not(windows))] +const IDLE_TIMEOUT: Duration = Duration::from_millis(8); + +#[cfg(windows)] +const IDLE_TIMEOUT: Duration = Duration::from_millis(50); + +/// After a burst is flushed, suppress Enter-as-submit for this duration. +/// Handles terminals that insert a small gap between the last pasted +/// character and a trailing newline. +#[cfg(not(windows))] +const ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(100); + +#[cfg(windows)] +const ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(250); + +/// Minimum characters in a burst to classify it as paste (not fast typing). +const MIN_BURST_LEN: usize = 3; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Action the caller should take after feeding a character to the detector. +#[derive(Debug, PartialEq)] +pub enum CharAction { + /// Character was consumed into the burst buffer. Do not insert it. + Consumed, + /// Character was consumed, and `n` previously inserted characters should + /// be deleted from the input before continuing (retro-capture). + RetroCapture(usize), + /// Not a burst -- insert this character normally. + Passthrough(char), +} + +/// Action produced by [`PasteBurstDetector::tick`] when a timeout fires. +#[derive(Debug, PartialEq)] +pub enum FlushAction { + /// The held character timed out without a follow-up. Emit as normal input. + EmitChar(char), + /// A burst completed. Emit the accumulated text as a paste payload. + EmitPaste(String), +} + +// --------------------------------------------------------------------------- +// State machine +// --------------------------------------------------------------------------- + +#[derive(Debug)] +enum BurstState { + /// No active burst. + Idle, + /// A single fast character is being held. If a second arrives within + /// `CHAR_INTERVAL`, both move into `Buffering`. Otherwise the held + /// character is emitted as a normal keystroke on the next `tick()`. + Pending { held_char: char, received_at: Instant, retro_prefix: Vec }, + /// Actively accumulating a rapid character stream. + Buffering, +} + +/// Detects rapid character input streams (paste events delivered as +/// individual key events) and buffers them into a single paste payload. +#[derive(Debug)] +pub struct PasteBurstDetector { + state: BurstState, + /// Accumulated characters during `Buffering` state. + buffer: String, + /// Timestamp of the last character fed to the detector. + last_char_time: Option, + /// Last recently-inserted passthrough characters (chronological, newest at end). + /// Used for retro-capturing leaked leading characters when a burst is confirmed. + recent_passthrough: std::collections::VecDeque<(char, Instant)>, + /// After a burst flush, suppress Enter-as-submit until this instant. + enter_suppress_until: Option, +} + +impl PasteBurstDetector { + pub fn new() -> Self { + Self { + state: BurstState::Idle, + buffer: String::new(), + last_char_time: None, + recent_passthrough: std::collections::VecDeque::with_capacity(2), + enter_suppress_until: None, + } + } + + /// Feed a printable character event. Returns whether the caller should + /// insert it or whether the detector consumed it. + pub fn on_char(&mut self, ch: char, now: Instant) -> CharAction { + let is_fast = self + .last_char_time + .is_some_and(|last| now.saturating_duration_since(last) <= CHAR_INTERVAL); + self.last_char_time = Some(now); + + match &self.state { + BurstState::Idle => { + if is_fast { + // A character arrived quickly after the previous one + // (which was already inserted as passthrough). Transition + // to Pending to hold this one and see if a burst follows. + self.state = BurstState::Pending { + held_char: ch, + received_at: now, + retro_prefix: self.collect_retro_prefix(now), + }; + tracing::debug!( + ch = %debug_char(ch), + recent_passthrough = self.recent_passthrough.len(), + "paste_burst: idle -> pending" + ); + CharAction::Consumed + } else { + // Normal typing speed -- pass through immediately. + self.push_recent_passthrough(ch, now); + tracing::debug!(ch = %debug_char(ch), "paste_burst: passthrough"); + CharAction::Passthrough(ch) + } + } + BurstState::Pending { held_char, received_at, retro_prefix } => { + let within_pending_window = + now.saturating_duration_since(*received_at) <= IDLE_TIMEOUT; + if is_fast || within_pending_window { + // Second fast character confirms a burst is starting. + let held = *held_char; + let retro_len = retro_prefix.len(); + self.buffer.clear(); + for prefix in retro_prefix { + self.buffer.push(*prefix); + } + self.buffer.push(held); + self.buffer.push(ch); + self.state = BurstState::Buffering; + self.recent_passthrough.clear(); + tracing::debug!( + held = %debug_char(held), + ch = %debug_char(ch), + retro_len, + within_pending_window, + buffer = %debug_text(&self.buffer), + "paste_burst: pending -> buffering" + ); + if retro_len > 0 { + CharAction::RetroCapture(retro_len) + } else { + CharAction::Consumed + } + } else { + // Gap too long -- the held char was a false alarm. + // Emit both characters as normal typing by releasing the + // held char and returning to Idle for the current char. + let prev = *held_char; + self.state = BurstState::Idle; + self.push_recent_passthrough(prev, now); + self.push_recent_passthrough(ch, now); + tracing::debug!( + held = %debug_char(prev), + ch = %debug_char(ch), + "paste_burst: pending false alarm" + ); + CharAction::Passthrough(prev) + } + } + BurstState::Buffering => { + // Once a burst is confirmed, keep buffering until idle timeout. + // This tolerates Windows scheduling jitter between pasted chars. + self.buffer.push(ch); + tracing::debug!( + ch = %debug_char(ch), + buffer_len = self.buffer.chars().count(), + "paste_burst: buffering append" + ); + CharAction::Consumed + } + } + } + + /// Feed an Enter key event. Returns `true` if Enter should be treated + /// as a newline (routed through the paste buffer) rather than a submit action. + /// + /// This covers two cases: + /// 1. Enter arrives while actively buffering a burst (append newline). + /// 2. Enter arrives within the post-burst suppression window. + pub fn on_enter(&mut self, now: Instant) -> bool { + match &self.state { + BurstState::Buffering => { + self.buffer.push('\n'); + self.last_char_time = Some(now); + tracing::debug!(buffer = %debug_text(&self.buffer), "paste_burst: enter during buffering"); + true + } + BurstState::Pending { held_char, .. } => { + // Promote held char + Enter into a buffering burst. + let held = *held_char; + self.buffer.clear(); + self.buffer.push(held); + self.buffer.push('\n'); + self.state = BurstState::Buffering; + self.last_char_time = Some(now); + tracing::debug!( + held = %debug_char(held), + buffer = %debug_text(&self.buffer), + "paste_burst: enter promoted pending -> buffering" + ); + true + } + BurstState::Idle if self.should_suppress_enter(now) => { + self.buffer.clear(); + self.buffer.push('\n'); + self.state = BurstState::Buffering; + self.last_char_time = Some(now); + tracing::debug!("paste_burst: suppressed enter queued as newline"); + true + } + BurstState::Idle => { + tracing::debug!("paste_burst: enter not suppressed"); + false + } + } + } + + /// Check for timeouts and return any pending action. + /// Call once per drain cycle (after all events are processed). + pub fn tick(&mut self, now: Instant) -> Option { + match &self.state { + BurstState::Pending { held_char, received_at, .. } => { + if now.saturating_duration_since(*received_at) > IDLE_TIMEOUT { + let ch = *held_char; + self.state = BurstState::Idle; + self.push_recent_passthrough(ch, now); + tracing::debug!(ch = %debug_char(ch), "paste_burst: pending timeout emit char"); + Some(FlushAction::EmitChar(ch)) + } else { + None + } + } + BurstState::Buffering => { + let idle = self + .last_char_time + .is_some_and(|last| now.saturating_duration_since(last) > IDLE_TIMEOUT); + if idle { + let text = self.flush_buffer(now); + if text.is_empty() { + None + } else { + tracing::debug!(text = %debug_text(&text), "paste_burst: idle timeout emit paste"); + Some(FlushAction::EmitPaste(text)) + } + } else { + None + } + } + BurstState::Idle => None, + } + } + + /// Whether Enter should be suppressed (treated as newline, not submit). + pub fn should_suppress_enter(&self, now: Instant) -> bool { + self.enter_suppress_until.is_some_and(|until| now <= until) + } + + /// Whether the detector is actively buffering characters. + #[must_use] + pub fn is_buffering(&self) -> bool { + matches!(self.state, BurstState::Buffering | BurstState::Pending { .. }) + } + + /// Reset burst state on non-character key events (arrows, Esc, etc.). + /// Prevents state from leaking across unrelated input. + pub fn on_non_char_key(&mut self, now: Instant) { + if matches!(self.state, BurstState::Buffering) { + let dropped = self.flush_buffer(now); + tracing::debug!(text = %debug_text(&dropped), "paste_burst: non-char dropped buffered text"); + } else if let BurstState::Pending { .. } = &self.state { + // Drop the held char -- non-char input breaks any potential burst. + self.state = BurstState::Idle; + tracing::debug!("paste_burst: non-char cleared pending state"); + } + self.last_char_time = None; + self.recent_passthrough.clear(); + } + + /// Drain the buffer and transition to Idle. If the buffer meets the + /// minimum burst length, it activates the enter suppression window. + /// Returns the buffer contents (may be empty). + fn flush_buffer(&mut self, now: Instant) -> String { + let text = std::mem::take(&mut self.buffer); + self.state = BurstState::Idle; + self.recent_passthrough.clear(); + if text.chars().count() >= MIN_BURST_LEN { + self.enter_suppress_until = Some(now + ENTER_SUPPRESS_WINDOW); + } + text + } + + fn push_recent_passthrough(&mut self, ch: char, now: Instant) { + self.recent_passthrough.push_back((ch, now)); + while self.recent_passthrough.len() > 2 { + let _ = self.recent_passthrough.pop_front(); + } + } + + fn collect_retro_prefix(&self, now: Instant) -> Vec { + let mut rev = Vec::with_capacity(self.recent_passthrough.len()); + let mut prev_time = now; + for (ch, at) in self.recent_passthrough.iter().rev() { + if prev_time.saturating_duration_since(*at) <= CHAR_INTERVAL { + rev.push(*ch); + prev_time = *at; + } else { + break; + } + } + rev.reverse(); + rev + } +} + +impl Default for PasteBurstDetector { + fn default() -> Self { + Self::new() + } +} + +fn debug_char(ch: char) -> String { + ch.escape_default().collect() +} + +fn debug_text(text: &str) -> String { + const MAX_CHARS: usize = 40; + let mut out = String::new(); + let mut iter = text.chars(); + for _ in 0..MAX_CHARS { + let Some(ch) = iter.next() else { + return out; + }; + out.extend(ch.escape_default()); + } + if iter.next().is_some() { + out.push_str("..."); + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn fast(base: Instant, ms: u64) -> Instant { + base + Duration::from_millis(ms) + } + + fn after_idle(base: Instant) -> Instant { + base + IDLE_TIMEOUT + Duration::from_millis(10) + } + + #[test] + fn single_char_passes_through() { + let mut d = PasteBurstDetector::new(); + let now = Instant::now(); + let action = d.on_char('a', now); + assert_eq!(action, CharAction::Passthrough('a')); + } + + #[test] + fn two_fast_chars_start_buffering() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + // First char passes through (no prior timing reference). + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + + // Second char within interval is held (Pending). + let t1 = fast(t0, 2); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + + // Third char within interval promotes to Buffering. + let t2 = fast(t1, 2); + assert_eq!(d.on_char('c', t2), CharAction::RetroCapture(1)); + + assert!(d.is_buffering()); + } + + #[test] + fn burst_flushes_on_idle_timeout() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 2); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + let t2 = fast(t1, 2); + assert_eq!(d.on_char('c', t2), CharAction::RetroCapture(1)); + let t3 = fast(t2, 2); + assert_eq!(d.on_char('d', t3), CharAction::Consumed); + + // Not yet timed out. + let t4 = fast(t3, 2); + assert!(d.tick(t4).is_none()); + + // After idle timeout. + let t5 = after_idle(t3); + let flush = d.tick(t5); + assert_eq!(flush, Some(FlushAction::EmitPaste("abcd".to_owned()))); + } + + #[test] + fn pending_char_emitted_on_timeout() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 2); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + + // Timeout without a third char. + let t2 = after_idle(t1); + assert_eq!(d.tick(t2), Some(FlushAction::EmitChar('b'))); + } + + #[test] + fn enter_suppressed_after_burst() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + assert_eq!(d.on_char('b', fast(t0, 2)), CharAction::Consumed); + assert_eq!(d.on_char('c', fast(t0, 4)), CharAction::RetroCapture(1)); + for i in 3_u8..=4 { + let t = fast(t0, u64::from(i) * 2); + assert_eq!(d.on_char(char::from(b'b' + i - 1), t), CharAction::Consumed); + } + + // Flush the burst. + let t_flush = after_idle(t0); + let flush = d.tick(t_flush); + assert!(matches!(flush, Some(FlushAction::EmitPaste(_)))); + + // Enter within suppression window is suppressed. + let t_enter = fast(t_flush, 10); + assert!(d.should_suppress_enter(t_enter)); + + // Enter after suppression window is not suppressed. + let late_ms = + u64::try_from((ENTER_SUPPRESS_WINDOW + Duration::from_millis(1)).as_millis()).unwrap(); + let t_late = fast(t_flush, late_ms); + assert!(!d.should_suppress_enter(t_late)); + } + + #[test] + fn suppressed_enter_is_emitted_as_newline_paste() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + assert_eq!(d.on_char('b', fast(t0, 2)), CharAction::Consumed); + assert_eq!(d.on_char('c', fast(t0, 4)), CharAction::RetroCapture(1)); + + let t_flush = after_idle(t0); + assert_eq!(d.tick(t_flush), Some(FlushAction::EmitPaste("abc".to_owned()))); + + let t_enter = fast(t_flush, 10); + assert!(d.on_enter(t_enter)); + assert!(d.is_buffering()); + + let t_newline_flush = after_idle(t_enter); + assert_eq!(d.tick(t_newline_flush), Some(FlushAction::EmitPaste("\n".to_owned()))); + } + + #[test] + fn enter_during_buffering_appends_newline() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 2); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + let t2 = fast(t1, 2); + assert_eq!(d.on_char('c', t2), CharAction::RetroCapture(1)); + + // Enter during buffering. + let t3 = fast(t2, 2); + assert!(d.on_enter(t3)); + assert!(d.is_buffering()); + + // Continue buffering after Enter. + let t4 = fast(t3, 2); + assert_eq!(d.on_char('d', t4), CharAction::Consumed); + + // Flush. + let t5 = after_idle(t4); + let flush = d.tick(t5); + assert_eq!(flush, Some(FlushAction::EmitPaste("abc\nd".to_owned()))); + } + + #[test] + fn non_char_key_resets_state() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 2); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + + // Non-char key resets. + let t2 = fast(t1, 2); + d.on_non_char_key(t2); + assert!(!d.is_buffering()); + } + + #[test] + fn slow_typing_never_triggers_burst() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + for i in 0_u8..10 { + let t = fast(t0, u64::from(i) * 200); // 200ms apart = human typing. + let ch = char::from(b'a' + (i % 26)); + assert_eq!(d.on_char(ch, t), CharAction::Passthrough(ch)); + } + assert!(!d.is_buffering()); + } + + #[test] + fn sub_threshold_burst_emits_chars_not_paste() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + // Only 2 fast chars (below MIN_BURST_LEN of 3). + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 2); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); // held in Pending + + // Third char is slow. + let t2 = fast(t1, 200); + // 'b' gets emitted as passthrough, 'c' becomes the new pending hold. + assert_eq!(d.on_char('c', t2), CharAction::Passthrough('b')); + } + + #[test] + fn enter_in_pending_promotes_to_buffering() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 2); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + + // Enter while Pending -> promotes to Buffering with held_char + newline. + let t2 = fast(t1, 2); + assert!(d.on_enter(t2)); + assert!(d.is_buffering()); + + // Flush to verify buffer contents. + let t3 = after_idle(t2); + let flush = d.tick(t3); + assert_eq!(flush, Some(FlushAction::EmitPaste("b\n".to_owned()))); + } + + #[test] + fn retro_capture_first_char_on_burst_confirm() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 2); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + let t2 = fast(t1, 2); + assert_eq!(d.on_char('c', t2), CharAction::RetroCapture(1)); + + let t3 = after_idle(t2); + assert_eq!(d.tick(t3), Some(FlushAction::EmitPaste("abc".to_owned()))); + } + + #[cfg(windows)] + #[test] + fn windows_slower_burst_still_detected() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 20); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + let t2 = fast(t1, 20); + assert_eq!(d.on_char('c', t2), CharAction::RetroCapture(1)); + let t3 = fast(t2, 25); + assert_eq!(d.on_char('d', t3), CharAction::Consumed); + + let t4 = fast(t3, 80); + assert_eq!(d.tick(t4), Some(FlushAction::EmitPaste("abcd".to_owned()))); + } + + #[cfg(windows)] + #[test] + fn windows_buffering_gap_does_not_drop_text() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 20); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + let t2 = fast(t1, 20); + assert_eq!(d.on_char('c', t2), CharAction::RetroCapture(1)); + + // Gap above CHAR_INTERVAL but below IDLE_TIMEOUT should stay in burst. + let t3 = fast(t2, 40); + assert_eq!(d.on_char('d', t3), CharAction::Consumed); + + let t4 = fast(t3, 80); + assert_eq!(d.tick(t4), Some(FlushAction::EmitPaste("abcd".to_owned()))); + } + + #[cfg(windows)] + #[test] + fn windows_pending_confirmation_tolerates_jitter_within_idle_timeout() { + let mut d = PasteBurstDetector::new(); + let t0 = Instant::now(); + + assert_eq!(d.on_char('a', t0), CharAction::Passthrough('a')); + let t1 = fast(t0, 20); + assert_eq!(d.on_char('b', t1), CharAction::Consumed); + + let jitter_ms = + u64::try_from((CHAR_INTERVAL + Duration::from_millis(5)).as_millis()).unwrap(); + let t2 = fast(t1, jitter_ms); + assert_eq!(d.on_char('c', t2), CharAction::RetroCapture(1)); + assert!(d.is_buffering()); + } +} diff --git a/claude-code-rust/src/app/permissions.rs b/claude-code-rust/src/app/permissions.rs new file mode 100644 index 0000000..2cfa93a --- /dev/null +++ b/claude-code-rust/src/app/permissions.rs @@ -0,0 +1,748 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::inline_interactions::{ + focus_next_inline_interaction, focused_interaction, focused_interaction_dirty_idx, + get_focused_interaction_tc, invalidate_if_changed, pop_next_valid_interaction_id, +}; +use super::{App, InvalidationLevel, MessageBlock}; +use crate::agent::model; +use crate::agent::model::PermissionOptionKind; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +fn focused_permission(app: &App) -> Option<&crate::app::InlinePermission> { + focused_interaction(app)?.pending_permission.as_ref() +} + +fn focused_option_index_by_kind(app: &App, kind: PermissionOptionKind) -> Option { + focused_option_index_where(app, |opt| opt.kind == kind) +} + +fn focused_option_index_where(app: &App, mut predicate: F) -> Option +where + F: FnMut(&model::PermissionOption) -> bool, +{ + focused_permission(app)?.options.iter().position(&mut predicate) +} + +fn is_ctrl_shortcut(modifiers: KeyModifiers) -> bool { + modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) +} + +fn is_ctrl_char_shortcut(key: KeyEvent, expected: char) -> bool { + is_ctrl_shortcut(key.modifiers) + && matches!(key.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&expected)) +} + +fn normalized_option_tokens(option: &model::PermissionOption) -> String { + let mut out = String::new(); + for ch in option.name.chars().chain(option.option_id.chars()) { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + } + } + out +} + +fn option_tokens(option: &model::PermissionOption) -> (bool, bool, bool, bool) { + let tokens = normalized_option_tokens(option); + let allow_like = + tokens.contains("allow") || tokens.contains("accept") || tokens.contains("approve"); + let reject_like = + tokens.contains("reject") || tokens.contains("deny") || tokens.contains("disallow"); + let persistent_like = tokens.contains("always") + || tokens.contains("dontask") + || tokens.contains("remember") + || tokens.contains("persist") + || tokens.contains("bypasspermissions"); + let session_like = tokens.contains("session") || tokens.contains("onesession"); + (allow_like, reject_like, persistent_like, session_like) +} + +fn option_is_allow_once_fallback(option: &model::PermissionOption) -> bool { + let (allow_like, reject_like, persistent_like, session_like) = option_tokens(option); + allow_like && !reject_like && !persistent_like && !session_like +} + +fn option_is_allow_always_fallback(option: &model::PermissionOption) -> bool { + let (allow_like, reject_like, persistent_like, _) = option_tokens(option); + allow_like && !reject_like && persistent_like +} + +fn option_is_allow_non_once_fallback(option: &model::PermissionOption) -> bool { + let (allow_like, reject_like, persistent_like, session_like) = option_tokens(option); + allow_like && !reject_like && (persistent_like || session_like) +} + +fn option_is_reject_once_fallback(option: &model::PermissionOption) -> bool { + let (allow_like, reject_like, persistent_like, _) = option_tokens(option); + reject_like && !allow_like && !persistent_like +} + +fn option_is_reject_fallback(option: &model::PermissionOption) -> bool { + let (allow_like, reject_like, _, _) = option_tokens(option); + reject_like && !allow_like +} + +pub(super) fn focused_permission_is_plan_approval(app: &App) -> bool { + focused_permission(app).is_some_and(|pending| { + pending.options.iter().any(|opt| { + matches!(opt.kind, PermissionOptionKind::PlanApprove | PermissionOptionKind::PlanReject) + }) + }) +} + +fn move_permission_option_left(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut permission) = tc.pending_permission + { + let next = permission.selected_index.saturating_sub(1); + if next != permission.selected_index { + permission.selected_index = next; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn move_permission_option_right(app: &mut App, option_count: usize) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut permission) = tc.pending_permission + && permission.selected_index + 1 < option_count + { + permission.selected_index += 1; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn handle_permission_option_keys( + app: &mut App, + key: KeyEvent, + interaction_has_focus: bool, + option_count: usize, + plan_approval: bool, +) -> Option { + if !interaction_has_focus { + return None; + } + match key.code { + KeyCode::Left if option_count > 0 => { + move_permission_option_left(app); + Some(true) + } + KeyCode::Right if option_count > 0 => { + move_permission_option_right(app, option_count); + Some(true) + } + KeyCode::Up if plan_approval && option_count > 0 => { + move_permission_option_left(app); + Some(true) + } + KeyCode::Down if plan_approval && option_count > 0 => { + move_permission_option_right(app, option_count); + Some(true) + } + KeyCode::Enter if option_count > 0 => { + respond_permission(app, None); + Some(true) + } + KeyCode::Esc => { + if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::RejectOnce) + .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::RejectAlways)) + .or_else(|| focused_option_index_where(app, option_is_reject_fallback)) + { + respond_permission(app, Some(idx)); + Some(true) + } else if option_count > 0 { + respond_permission(app, Some(option_count - 1)); + Some(true) + } else { + Some(false) + } + } + _ => None, + } +} + +fn handle_permission_quick_shortcuts(app: &mut App, key: KeyEvent) -> Option { + if !matches!(key.code, KeyCode::Char(_)) { + return None; + } + if focused_permission_is_plan_approval(app) { + if is_ctrl_char_shortcut(key, 'y') + || is_ctrl_char_shortcut(key, 'a') + || is_ctrl_char_shortcut(key, 'n') + { + return Some(false); + } + if !is_ctrl_shortcut(key.modifiers) { + if matches!(key.code, KeyCode::Char('y' | 'Y')) + && let Some(idx) = + focused_option_index_by_kind(app, PermissionOptionKind::PlanApprove) + { + respond_permission(app, Some(idx)); + return Some(true); + } + if matches!(key.code, KeyCode::Char('n' | 'N')) + && let Some(idx) = + focused_option_index_by_kind(app, PermissionOptionKind::PlanReject) + { + respond_permission(app, Some(idx)); + return Some(true); + } + } + return None; + } + if is_ctrl_char_shortcut(key, 'y') { + if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::AllowOnce) + .or_else(|| focused_option_index_where(app, option_is_allow_once_fallback)) + .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::AllowSession)) + .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::AllowAlways)) + .or_else(|| focused_option_index_where(app, option_is_allow_always_fallback)) + .or_else(|| focused_option_index_where(app, option_is_allow_non_once_fallback)) + { + respond_permission(app, Some(idx)); + return Some(true); + } + return Some(false); + } + if is_ctrl_char_shortcut(key, 'a') { + if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::AllowSession) + .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::AllowAlways)) + .or_else(|| focused_option_index_where(app, option_is_allow_non_once_fallback)) + { + respond_permission(app, Some(idx)); + return Some(true); + } + return Some(false); + } + if is_ctrl_char_shortcut(key, 'n') { + if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::RejectOnce) + .or_else(|| focused_option_index_where(app, option_is_reject_once_fallback)) + { + respond_permission(app, Some(idx)); + return Some(true); + } + return Some(false); + } + None +} + +pub(super) fn handle_permission_key( + app: &mut App, + key: KeyEvent, + interaction_has_focus: bool, +) -> bool { + let option_count = focused_permission(app).map_or(0, |permission| permission.options.len()); + let plan_approval = focused_permission_is_plan_approval(app); + + if let Some(consumed) = + handle_permission_option_keys(app, key, interaction_has_focus, option_count, plan_approval) + { + return consumed; + } + if let Some(consumed) = handle_permission_quick_shortcuts(app, key) { + return consumed; + } + false +} + +fn respond_permission(app: &mut App, override_index: Option) { + let Some(tool_id) = pop_next_valid_interaction_id(app) else { + return; + }; + + let Some((mi, bi)) = app.tool_call_index.get(&tool_id).copied() else { + return; + }; + let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + else { + return; + }; + let tc = tc.as_mut(); + let mut invalidated = false; + if let Some(pending) = tc.pending_permission.take() { + let idx = override_index.unwrap_or(pending.selected_index); + if let Some(opt) = pending.options.get(idx) { + tracing::debug!( + "permission selection: tool_call_id={} option_id={} option_name={} option_kind={:?}", + tool_id, + opt.option_id, + opt.name, + opt.kind + ); + let _ = pending.response_tx.send(model::RequestPermissionResponse::new( + model::RequestPermissionOutcome::Selected(model::SelectedPermissionOutcome::new( + opt.option_id.clone(), + )), + )); + } else { + tracing::warn!( + "permission selection index out of bounds: tool_call_id={} selected_index={} options={}", + tool_id, + idx, + pending.options.len() + ); + } + tc.mark_tool_call_layout_dirty(); + invalidated = true; + } + if invalidated { + app.sync_render_cache_slot(mi, bi); + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } + + focus_next_inline_interaction(app); +} + +#[cfg(test)] +fn respond_permission_cancel(app: &mut App) { + let Some(tool_id) = pop_next_valid_interaction_id(app) else { + return; + }; + + let Some((mi, bi)) = app.tool_call_index.get(&tool_id).copied() else { + return; + }; + let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + else { + return; + }; + let tc = tc.as_mut(); + if let Some(pending) = tc.pending_permission.take() { + let _ = pending.response_tx.send(model::RequestPermissionResponse::new( + model::RequestPermissionOutcome::Cancelled, + )); + tc.mark_tool_call_layout_dirty(); + app.sync_render_cache_slot(mi, bi); + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } + + focus_next_inline_interaction(app); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ + App, AppStatus, BlockCache, ChatMessage, IncrementalMarkdown, InlinePermission, + MessageBlock, MessageRole, ToolCallInfo, + }; + use pretty_assertions::assert_eq; + use tokio::sync::oneshot; + + fn test_tool_call(id: &str) -> ToolCallInfo { + ToolCallInfo { + id: id.to_owned(), + title: format!("Tool {id}"), + sdk_tool_name: "Read".to_owned(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status: model::ToolCallStatus::InProgress, + content: Vec::new(), + hidden: false, + terminal_id: None, + terminal_command: None, + terminal_output: None, + terminal_output_len: 0, + terminal_bytes_seen: 0, + terminal_snapshot_mode: crate::app::TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + } + } + + fn assistant_tool_msg(tc: ToolCallInfo) -> ChatMessage { + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(tc))], + usage: None, + } + } + + fn allow_options() -> Vec { + vec![ + model::PermissionOption::new( + "allow-once", + "Allow once", + PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "allow-always", + "Allow always", + PermissionOptionKind::AllowAlways, + ), + model::PermissionOption::new("reject-once", "Reject", PermissionOptionKind::RejectOnce), + ] + } + + fn add_permission( + app: &mut App, + tool_id: &str, + options: Vec, + focused: bool, + ) -> oneshot::Receiver { + let msg_idx = app.messages.len(); + app.messages.push(assistant_tool_msg(test_tool_call(tool_id))); + app.index_tool_call(tool_id.to_owned(), msg_idx, 0); + + let (tx, rx) = oneshot::channel(); + if let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(msg_idx).and_then(|m| m.blocks.get_mut(0)) + { + tc.pending_permission = + Some(InlinePermission { options, response_tx: tx, selected_index: 0, focused }); + } + app.pending_interaction_ids.push(tool_id.to_owned()); + rx + } + + fn permission_focused(app: &App, tool_id: &str) -> bool { + let Some((mi, bi)) = app.lookup_tool_call(tool_id) else { + return false; + }; + let Some(MessageBlock::ToolCall(tc)) = app.messages.get(mi).and_then(|m| m.blocks.get(bi)) + else { + return false; + }; + tc.pending_permission.as_ref().is_some_and(|permission| permission.focused) + } + + #[test] + fn step2_up_down_rotates_permission_focus_and_enter_targets_focused_prompt() { + let mut app = App::test_default(); + app.status = AppStatus::Ready; + let mut rx1 = add_permission(&mut app, "perm-1", allow_options(), true); + let mut rx2 = add_permission(&mut app, "perm-2", allow_options(), false); + + assert_eq!(app.pending_interaction_ids, vec!["perm-1", "perm-2"]); + assert!(permission_focused(&app, "perm-1")); + assert!(!permission_focused(&app, "perm-2")); + + let consumed = crate::app::inline_interactions::handle_interaction_focus_cycle( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + true, + false, + ); + assert_eq!(consumed, Some(true)); + assert_eq!(app.pending_interaction_ids, vec!["perm-2", "perm-1"]); + assert!(permission_focused(&app, "perm-2")); + assert!(!permission_focused(&app, "perm-1")); + + let consumed = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + true, + ); + assert!(consumed); + + let resp2 = rx2.try_recv().expect("focused permission should receive response"); + let model::RequestPermissionOutcome::Selected(sel2) = resp2.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(sel2.option_id.clone(), "allow-once"); + assert!(matches!(rx1.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); + } + + #[test] + fn step3_lowercase_a_is_not_consumed_by_permission_shortcuts() { + let mut app = App::test_default(); + let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); + + let consumed = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + true, + ); + + assert!(!consumed, "lowercase 'a' should flow to normal typing"); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); + assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + } + + #[test] + fn step4_ctrl_y_maps_to_allow_once_kind_and_only_resolves_one_permission() { + let mut app = App::test_default(); + let mut rx1 = add_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow-always", + "Allow always", + PermissionOptionKind::AllowAlways, + ), + model::PermissionOption::new( + "allow-once", + "Allow once", + PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "reject-once", + "Reject", + PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + let mut rx2 = add_permission(&mut app, "perm-2", allow_options(), false); + + let consumed = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), + true, + ); + assert!(consumed); + + let resp1 = rx1.try_recv().expect("first permission should be answered"); + let model::RequestPermissionOutcome::Selected(sel1) = resp1.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(sel1.option_id.clone(), "allow-once"); + assert_eq!(app.pending_interaction_ids, vec!["perm-2"]); + assert!(matches!(rx2.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + } + + #[test] + fn plain_y_and_n_are_not_consumed() { + let mut app = App::test_default(); + let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); + + let consumed_y = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), + true, + ); + let consumed_n = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + true, + ); + + assert!(!consumed_y); + assert!(!consumed_n); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); + assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + } + + #[test] + fn ctrl_n_rejects_focused_permission() { + let mut app = App::test_default(); + let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); + + let consumed = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + true, + ); + assert!(consumed); + assert!(app.pending_interaction_ids.is_empty()); + + let resp = rx.try_recv().expect("permission should be answered by ctrl+n"); + let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(sel.option_id.clone(), "reject-once"); + } + + #[test] + fn ctrl_n_does_not_trigger_reject_always() { + let mut app = App::test_default(); + let mut rx = add_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow-once", + "Allow once", + PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "reject-always", + "Reject always", + PermissionOptionKind::RejectAlways, + ), + ], + true, + ); + + let consumed = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + true, + ); + assert!(!consumed); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); + assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + } + + #[test] + fn ctrl_a_matches_allow_always_by_label_when_kind_is_missing() { + let mut app = App::test_default(); + let mut rx = add_permission( + &mut app, + "perm-1", + vec![ + model::PermissionOption::new( + "allow-once", + "Allow once", + PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "allow-always", + "Allow always", + PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "reject-once", + "Reject", + PermissionOptionKind::RejectOnce, + ), + ], + true, + ); + + let consumed = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), + true, + ); + assert!(consumed); + + let resp = rx.try_recv().expect("permission should be answered by ctrl+a fallback"); + let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(sel.option_id.clone(), "allow-always"); + } + + #[test] + fn ctrl_a_accepts_uppercase_with_shift_modifier() { + let mut app = App::test_default(); + let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); + + let consumed = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Char('A'), KeyModifiers::CONTROL | KeyModifiers::SHIFT), + true, + ); + assert!(consumed); + + let resp = rx.try_recv().expect("permission should be answered by uppercase ctrl+a"); + let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { + panic!("expected selected permission response"); + }; + assert_eq!(sel.option_id.clone(), "allow-always"); + } + + #[test] + fn left_right_not_consumed_when_permission_not_focused() { + let mut app = App::test_default(); + let mut rx = add_permission(&mut app, "perm-1", allow_options(), false); + + let consumed_left = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + false, + ); + let consumed_right = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + false, + ); + + assert!(!consumed_left); + assert!(!consumed_right); + assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + } + + #[test] + fn enter_not_consumed_when_permission_not_focused() { + let mut app = App::test_default(); + let mut rx = add_permission(&mut app, "perm-1", allow_options(), false); + + let consumed = handle_permission_key( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + false, + ); + + assert!(!consumed); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); + assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + } + + #[test] + fn keeps_non_tool_blocks_untouched() { + let app = App::test_default(); + let _ = IncrementalMarkdown::default(); + assert!(app.messages.is_empty()); + } + + #[test] + fn single_focused_permission_consumes_up_down_without_rotation() { + let mut app = App::test_default(); + let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); + app.viewport.scroll_target = 7; + + let consumed_up = crate::app::inline_interactions::handle_interaction_focus_cycle( + &mut app, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + true, + false, + ); + let consumed_down = crate::app::inline_interactions::handle_interaction_focus_cycle( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + true, + false, + ); + + assert_eq!(consumed_up, Some(true)); + assert_eq!(consumed_down, Some(true)); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); + assert_eq!(app.viewport.scroll_target, 7); + assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + } + + #[test] + fn esc_cancels_permission_when_no_reject_option_exists() { + let mut app = App::test_default(); + let mut rx = add_permission( + &mut app, + "perm-1", + vec![model::PermissionOption::new( + "allow-once", + "Allow once", + PermissionOptionKind::AllowOnce, + )], + true, + ); + + respond_permission_cancel(&mut app); + + let resp = rx.try_recv().expect("permission should be cancelled"); + assert!(matches!(resp.outcome, model::RequestPermissionOutcome::Cancelled)); + } +} diff --git a/claude-code-rust/src/app/plugins/cli.rs b/claude-code-rust/src/app/plugins/cli.rs new file mode 100644 index 0000000..4d8483d --- /dev/null +++ b/claude-code-rust/src/app/plugins/cli.rs @@ -0,0 +1,319 @@ +use super::{ + InstalledPluginEntry, MarketplaceEntry, MarketplaceSourceEntry, PluginCapability, + PluginsInventorySnapshot, +}; +use serde::Deserialize; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Deserialize)] +struct InstalledPluginJson { + id: String, + version: Option, + scope: String, + enabled: bool, + #[serde(rename = "installedAt")] + installed_at: Option, + #[serde(rename = "lastUpdated")] + last_updated: Option, + #[serde(rename = "projectPath")] + project_path: Option, + #[serde(rename = "mcpServers")] + mcp_servers: Option, +} + +#[derive(Debug, Deserialize)] +struct MarketplaceListJson { + available: Vec, +} + +#[derive(Debug, Deserialize)] +struct AvailablePluginJson { + #[serde(rename = "pluginId")] + plugin_id: String, + name: String, + description: Option, + #[serde(rename = "marketplaceName")] + marketplace_name: Option, + version: Option, + #[serde(rename = "installCount")] + install_count: Option, + source: Option, +} + +#[derive(Debug, Deserialize)] +struct MarketplaceSourceJson { + name: String, + source: Option, + repo: Option, +} + +pub(super) async fn refresh_inventory( + cwd_raw: String, + cached_claude_path: Option, +) -> Result<(PluginsInventorySnapshot, PathBuf), String> { + tokio::task::spawn_blocking(move || { + let claude_path = resolve_claude_path(cached_claude_path)?; + let snapshot = refresh_inventory_blocking(&claude_path, &cwd_raw)?; + Ok((snapshot, claude_path)) + }) + .await + .map_err(|error| format!("Plugin inventory task failed: {error}"))? +} + +pub(super) async fn run_cli_command_and_refresh( + cwd_raw: String, + cached_claude_path: Option, + args: Vec, +) -> Result<(PluginsInventorySnapshot, PathBuf), String> { + tokio::task::spawn_blocking(move || { + let claude_path = resolve_claude_path(cached_claude_path)?; + run_command(&claude_path, &cwd_raw, &args)?; + let snapshot = refresh_inventory_blocking(&claude_path, &cwd_raw)?; + Ok((snapshot, claude_path)) + }) + .await + .map_err(|error| format!("Plugin CLI action task failed: {error}"))? +} + +fn resolve_claude_path(cached_claude_path: Option) -> Result { + if let Some(path) = cached_claude_path + && path.is_file() + { + return Ok(path); + } + which::which("claude").map_err(|_| "claude CLI not found in PATH".to_owned()) +} + +fn refresh_inventory_blocking( + claude_path: &Path, + cwd_raw: &str, +) -> Result { + let installed = parse_json_command::>( + claude_path, + cwd_raw, + &["plugin", "list", "--json"], + )?; + let available = parse_json_command::( + claude_path, + cwd_raw, + &["plugin", "list", "--available", "--json"], + )?; + let marketplaces = parse_json_command::>( + claude_path, + cwd_raw, + &["plugin", "marketplace", "list", "--json"], + )?; + + let mut installed_entries = installed + .into_iter() + .map(|entry| InstalledPluginEntry { + id: entry.id, + version: entry.version, + scope: entry.scope, + enabled: entry.enabled, + installed_at: entry.installed_at, + last_updated: entry.last_updated, + project_path: entry.project_path, + capability: if entry.mcp_servers.is_some() { + PluginCapability::Mcp + } else { + PluginCapability::Skill + }, + }) + .collect::>(); + installed_entries.sort_by_cached_key(|entry| entry.id.to_ascii_lowercase()); + + let mut marketplace_entries = available + .available + .into_iter() + .map(|entry| MarketplaceEntry { + plugin_id: entry.plugin_id, + name: entry.name, + description: entry.description, + marketplace_name: entry.marketplace_name, + version: entry.version, + install_count: entry.install_count, + source: entry.source, + }) + .collect::>(); + marketplace_entries.sort_by_cached_key(|entry| { + ( + entry.marketplace_name.as_deref().unwrap_or_default().to_ascii_lowercase(), + entry.name.to_ascii_lowercase(), + ) + }); + + let mut marketplace_sources = marketplaces + .into_iter() + .map(|entry| MarketplaceSourceEntry { + name: entry.name, + source: entry.source, + repo: entry.repo, + }) + .collect::>(); + marketplace_sources.sort_by_cached_key(|entry| entry.name.to_ascii_lowercase()); + + Ok(PluginsInventorySnapshot { + installed: installed_entries, + marketplace: marketplace_entries, + marketplaces: marketplace_sources, + }) +} + +fn parse_json_command(claude_path: &Path, cwd_raw: &str, args: &[&str]) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let output = Command::new(claude_path) + .args(args) + .current_dir(cwd_raw) + .output() + .map_err(|error| format!("Failed to run `claude {}`: {error}", args.join(" ")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let exit_code = + output.status.code().map_or_else(|| "unknown".to_owned(), |code| code.to_string()); + let detail = if stderr.is_empty() { + format!("exit code {exit_code}") + } else { + format!("exit code {exit_code}: {stderr}") + }; + return Err(format!("`claude {}` failed: {detail}", args.join(" "))); + } + + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("Failed to parse JSON from `claude {}`: {error}", args.join(" "))) +} + +fn run_command(claude_path: &Path, cwd_raw: &str, args: &[String]) -> Result<(), String> { + let output = Command::new(claude_path) + .args(args) + .current_dir(cwd_raw) + .output() + .map_err(|error| format!("Failed to run `claude {}`: {error}", args.join(" ")))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let exit_code = + output.status.code().map_or_else(|| "unknown".to_owned(), |code| code.to_string()); + let detail = if stderr.is_empty() { + format!("exit code {exit_code}") + } else { + format!("exit code {exit_code}: {stderr}") + }; + Err(format!("`claude {}` failed: {detail}", args.join(" "))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_installed_plugin_entries() { + let json = r#" +[ + { + "id": "frontend-design@claude-plugins-official", + "version": "55b58ec6e564", + "scope": "local", + "enabled": false, + "installedAt": "2026-02-05T15:37:39.555Z", + "lastUpdated": "2026-03-02T18:10:00.820Z", + "projectPath": "C:\\work" + } +] +"#; + + let parsed = serde_json::from_str::>(json).expect("parse json"); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id, "frontend-design@claude-plugins-official"); + assert_eq!(parsed[0].scope, "local"); + assert!(!parsed[0].enabled); + assert_eq!(parsed[0].project_path.as_deref(), Some("C:\\work")); + } + + #[test] + fn detects_mcp_plugins_from_installed_payload() { + let json = r#" +[ + { + "id": "supabase@claude-plugins-official", + "scope": "local", + "enabled": true, + "mcpServers": { + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp" + } + } + } +] +"#; + + let parsed = serde_json::from_str::>(json).expect("parse json"); + let entry = InstalledPluginEntry { + id: parsed[0].id.clone(), + version: parsed[0].version.clone(), + scope: parsed[0].scope.clone(), + enabled: parsed[0].enabled, + installed_at: parsed[0].installed_at.clone(), + last_updated: parsed[0].last_updated.clone(), + project_path: parsed[0].project_path.clone(), + capability: if parsed[0].mcp_servers.is_some() { + PluginCapability::Mcp + } else { + PluginCapability::Skill + }, + }; + + assert_eq!(entry.capability, PluginCapability::Mcp); + } + + #[test] + fn parses_marketplace_entries_and_sources() { + let available_json = r#" +{ + "installed": [], + "available": [ + { + "pluginId": "frontend-design@claude-plugins-official", + "name": "frontend-design", + "description": "Create distinctive interfaces", + "marketplaceName": "claude-plugins-official", + "version": "1.0.0", + "source": "./plugins/frontend-design", + "installCount": 42 + } + ] +} +"#; + let source_json = r#" +[ + { + "name": "claude-plugins-official", + "source": "github", + "repo": "anthropics/claude-plugins-official" + } +] +"#; + + let parsed_available = + serde_json::from_str::(available_json).expect("parse available"); + let parsed_sources = + serde_json::from_str::>(source_json).expect("parse sources"); + + assert_eq!(parsed_available.available.len(), 1); + assert_eq!( + parsed_available.available[0].marketplace_name.as_deref(), + Some("claude-plugins-official") + ); + assert_eq!(parsed_available.available[0].install_count, Some(42)); + assert_eq!(parsed_sources[0].repo.as_deref(), Some("anthropics/claude-plugins-official")); + } +} diff --git a/claude-code-rust/src/app/plugins/mod.rs b/claude-code-rust/src/app/plugins/mod.rs new file mode 100644 index 0000000..1e1bfa9 --- /dev/null +++ b/claude-code-rust/src/app/plugins/mod.rs @@ -0,0 +1,1383 @@ +mod cli; + +use crate::agent::events::ClientEvent; +use crate::app::App; +use crate::app::config::{ + AddMarketplaceOverlayState, ConfigOverlayState, InstalledPluginActionKind, + InstalledPluginActionOverlayState, MarketplaceActionKind, MarketplaceActionsOverlayState, + PluginInstallActionKind, PluginInstallOverlayState, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde_json::Value; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +const INVENTORY_REFRESH_TTL: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PluginsViewTab { + #[default] + Installed, + Plugins, + Marketplace, +} + +impl PluginsViewTab { + pub const ALL: [Self; 3] = [Self::Installed, Self::Plugins, Self::Marketplace]; + + #[must_use] + pub const fn title(self) -> &'static str { + match self { + Self::Installed => "Installed", + Self::Plugins => "Plugins", + Self::Marketplace => "Marketplace", + } + } + + #[must_use] + pub const fn next(self) -> Self { + match self { + Self::Installed => Self::Plugins, + Self::Plugins => Self::Marketplace, + Self::Marketplace => Self::Installed, + } + } + + #[must_use] + pub const fn prev(self) -> Self { + match self { + Self::Installed => Self::Marketplace, + Self::Plugins => Self::Installed, + Self::Marketplace => Self::Plugins, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginCapability { + Skill, + Mcp, +} + +impl PluginCapability { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Skill => "SKILL", + Self::Mcp => "MCP", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledPluginEntry { + pub id: String, + pub version: Option, + pub scope: String, + pub enabled: bool, + pub installed_at: Option, + pub last_updated: Option, + pub project_path: Option, + pub capability: PluginCapability, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceEntry { + pub plugin_id: String, + pub name: String, + pub description: Option, + pub marketplace_name: Option, + pub version: Option, + pub install_count: Option, + pub source: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceSourceEntry { + pub name: String, + pub source: Option, + pub repo: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginsInventorySnapshot { + pub installed: Vec, + pub marketplace: Vec, + pub marketplaces: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PluginsState { + pub active_tab: PluginsViewTab, + pub search_focused: bool, + pub installed_search_query: String, + pub plugins_search_query: String, + pub installed_selected_index: usize, + pub plugins_selected_index: usize, + pub marketplace_selected_index: usize, + pub installed: Vec, + pub marketplace: Vec, + pub marketplaces: Vec, + pub loading: bool, + pub status_message: Option, + pub last_error: Option, + pub last_inventory_refresh_at: Option, + pub claude_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginsCliActionSuccess { + pub snapshot: PluginsInventorySnapshot, + pub message: String, + pub claude_path: PathBuf, +} + +impl PluginsState { + #[must_use] + pub fn selected_index_for(&self, tab: PluginsViewTab) -> usize { + match tab { + PluginsViewTab::Installed => self.installed_selected_index, + PluginsViewTab::Plugins => self.plugins_selected_index, + PluginsViewTab::Marketplace => self.marketplace_selected_index, + } + } + + pub fn set_selected_index_for(&mut self, tab: PluginsViewTab, index: usize) { + match tab { + PluginsViewTab::Installed => self.installed_selected_index = index, + PluginsViewTab::Plugins => self.plugins_selected_index = index, + PluginsViewTab::Marketplace => self.marketplace_selected_index = index, + } + } + + pub fn clear_feedback(&mut self) { + self.status_message = None; + self.last_error = None; + } + + #[must_use] + pub fn search_query_for(&self, tab: PluginsViewTab) -> &str { + match tab { + PluginsViewTab::Installed => &self.installed_search_query, + PluginsViewTab::Plugins => &self.plugins_search_query, + PluginsViewTab::Marketplace => "", + } + } + + pub fn active_search_query_mut(&mut self) -> Option<&mut String> { + match self.active_tab { + PluginsViewTab::Installed => Some(&mut self.installed_search_query), + PluginsViewTab::Plugins => Some(&mut self.plugins_search_query), + PluginsViewTab::Marketplace => None, + } + } +} + +pub(crate) fn handle_paste(app: &mut App, text: &str) -> bool { + if !search_enabled(app.plugins.active_tab) || !app.plugins.search_focused { + return false; + } + let normalized = normalize_single_line_input(text); + if normalized.is_empty() { + return false; + } + if let Some(query) = app.plugins.active_search_query_mut() { + query.push_str(&normalized); + reset_selection_for_active_tab(app); + return true; + } + false +} + +pub(crate) fn handle_key(app: &mut App, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (KeyCode::Left, KeyModifiers::NONE) => { + app.plugins.active_tab = app.plugins.active_tab.prev(); + app.plugins.search_focused = false; + clamp_selection(app); + true + } + (KeyCode::Right, KeyModifiers::NONE) => { + app.plugins.active_tab = app.plugins.active_tab.next(); + app.plugins.search_focused = false; + clamp_selection(app); + true + } + (KeyCode::Up, KeyModifiers::NONE) => { + if search_enabled(app.plugins.active_tab) + && !app.plugins.search_focused + && app.plugins.selected_index_for(app.plugins.active_tab) == 0 + { + app.plugins.search_focused = true; + } else if !app.plugins.search_focused { + move_selection(app, -1); + } + true + } + (KeyCode::Down, KeyModifiers::NONE) => { + if app.plugins.search_focused { + app.plugins.search_focused = false; + } else { + move_selection(app, 1); + } + true + } + (KeyCode::Enter, KeyModifiers::NONE) => { + if app.plugins.search_focused { + false + } else { + match app.plugins.active_tab { + PluginsViewTab::Installed => open_installed_actions_overlay(app), + PluginsViewTab::Plugins => open_plugin_install_overlay(app), + PluginsViewTab::Marketplace => open_marketplace_overlay(app), + } + } + } + (KeyCode::Backspace, KeyModifiers::NONE) => { + if search_enabled(app.plugins.active_tab) + && app.plugins.search_focused + && let Some(query) = app.plugins.active_search_query_mut() + && query.pop().is_some() + { + reset_selection_for_active_tab(app); + } + true + } + (KeyCode::Delete, KeyModifiers::NONE) => { + if search_enabled(app.plugins.active_tab) + && app.plugins.search_focused + && let Some(query) = app.plugins.active_search_query_mut() + && !query.is_empty() + { + query.clear(); + reset_selection_for_active_tab(app); + } + true + } + (KeyCode::Char(ch), modifiers) + if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => + { + if search_enabled(app.plugins.active_tab) + && app.plugins.search_focused + && let Some(query) = app.plugins.active_search_query_mut() + { + query.push(ch); + reset_selection_for_active_tab(app); + } + true + } + _ => false, + } +} + +pub(crate) fn request_inventory_refresh_if_needed(app: &mut App) { + if app.plugins.loading { + return; + } + if app + .plugins + .last_inventory_refresh_at + .is_some_and(|refreshed_at| refreshed_at.elapsed() < INVENTORY_REFRESH_TTL) + { + clamp_selection(app); + return; + } + request_inventory_refresh(app); +} + +pub(crate) fn request_inventory_refresh(app: &mut App) { + if tokio::runtime::Handle::try_current().is_err() { + return; + } + app.plugins.loading = true; + app.plugins.clear_feedback(); + app.plugins.status_message = Some("Refreshing plugin inventory...".to_owned()); + app.needs_redraw = true; + let event_tx = app.event_tx.clone(); + let cwd_context = app.cwd_raw.clone(); + let cwd_raw = app.cwd_raw.clone(); + let cached_claude_path = app.plugins.claude_path.clone(); + tokio::task::spawn_local(async move { + match cli::refresh_inventory(cwd_raw, cached_claude_path).await { + Ok((snapshot, claude_path)) => { + let _ = event_tx.send(crate::agent::events::ClientEvent::PluginsInventoryUpdated { + cwd_raw: cwd_context, + snapshot, + claude_path, + }); + } + Err(message) => { + let _ = event_tx.send( + crate::agent::events::ClientEvent::PluginsInventoryRefreshFailed { + cwd_raw: cwd_context, + message, + }, + ); + } + } + }); +} + +pub(crate) fn apply_inventory_refresh_success( + app: &mut App, + snapshot: PluginsInventorySnapshot, + claude_path: PathBuf, +) { + app.plugins.installed = snapshot.installed; + app.plugins.marketplace = snapshot.marketplace; + app.plugins.marketplaces = snapshot.marketplaces; + app.plugins.loading = false; + app.plugins.last_error = None; + app.plugins.last_inventory_refresh_at = Some(Instant::now()); + app.plugins.claude_path = Some(claude_path); + app.plugins.status_message = Some("Plugin inventory refreshed".to_owned()); + clamp_selection(app); +} + +pub(crate) fn apply_inventory_refresh_failure(app: &mut App, message: String) { + app.plugins.loading = false; + app.plugins.status_message = None; + app.plugins.last_error = Some(message); +} + +pub(crate) fn reset_for_session_change(app: &mut App) { + app.plugins.loading = false; + app.plugins.status_message = None; + app.plugins.last_error = None; + app.plugins.last_inventory_refresh_at = None; + app.plugins.installed.clear(); + app.plugins.marketplace.clear(); + app.plugins.marketplaces.clear(); + app.plugins.claude_path = None; + clamp_selection(app); +} + +pub(crate) fn clamp_selection(app: &mut App) { + let installed_len = filtered_installed(&app.plugins).len(); + let plugin_len = filtered_marketplace_plugins(&app.plugins).len(); + let marketplace_len = marketplace_row_count(&app.plugins); + app.plugins.installed_selected_index = + clamp_index(app.plugins.installed_selected_index, installed_len); + app.plugins.plugins_selected_index = + clamp_index(app.plugins.plugins_selected_index, plugin_len); + app.plugins.marketplace_selected_index = + clamp_index(app.plugins.marketplace_selected_index, marketplace_len); +} + +#[must_use] +pub(crate) fn filtered_installed(state: &PluginsState) -> Vec<&InstalledPluginEntry> { + state + .installed + .iter() + .filter(|entry| { + installed_entry_matches(entry, state.search_query_for(PluginsViewTab::Installed)) + }) + .collect() +} + +#[must_use] +pub(crate) fn ordered_installed<'a>( + state: &'a PluginsState, + current_project_raw: &str, +) -> Vec<&'a InstalledPluginEntry> { + let current_project = normalize_project_path(current_project_raw); + let mut relevant = Vec::new(); + let mut other = Vec::new(); + + for entry in filtered_installed(state) { + if is_relevant_installed_entry(entry, ¤t_project) { + relevant.push(entry); + } else { + other.push(entry); + } + } + + relevant.extend(other); + relevant +} + +#[must_use] +pub(crate) fn relevant_installed_count(state: &PluginsState, current_project_raw: &str) -> usize { + let current_project = normalize_project_path(current_project_raw); + filtered_installed(state) + .into_iter() + .filter(|entry| is_relevant_installed_entry(entry, ¤t_project)) + .count() +} + +#[must_use] +pub(crate) fn filtered_marketplace_plugins(state: &PluginsState) -> Vec<&MarketplaceEntry> { + state + .marketplace + .iter() + .filter(|entry| { + marketplace_plugin_matches(entry, state.search_query_for(PluginsViewTab::Plugins)) + }) + .collect() +} + +#[must_use] +pub(crate) fn visible_marketplaces(state: &PluginsState) -> Vec<&MarketplaceSourceEntry> { + state.marketplaces.iter().collect() +} + +#[must_use] +pub(crate) fn display_label(raw: &str) -> String { + let normalized = raw.replace('@', " from ").replace('-', " "); + let mut result = String::with_capacity(normalized.len()); + let mut capitalize_next = true; + + for ch in normalized.chars() { + if ch == ' ' { + capitalize_next = true; + result.push(ch); + continue; + } + + if capitalize_next { + result.extend(ch.to_uppercase()); + capitalize_next = false; + } else { + result.extend(ch.to_lowercase()); + } + } + + result +} + +pub(crate) fn handle_installed_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Up, KeyModifiers::NONE) => move_installed_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_installed_overlay_selection(app, 1), + (KeyCode::Enter, KeyModifiers::NONE) => execute_selected_installed_overlay_action(app), + _ => {} + } +} + +pub(crate) fn handle_plugin_install_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Up, KeyModifiers::NONE) => move_plugin_install_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_plugin_install_overlay_selection(app, 1), + (KeyCode::Enter, KeyModifiers::NONE) => execute_selected_plugin_install_action(app), + _ => {} + } +} + +pub(crate) fn handle_marketplace_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Up, KeyModifiers::NONE) => move_marketplace_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_marketplace_overlay_selection(app, 1), + (KeyCode::Enter, KeyModifiers::NONE) => execute_selected_marketplace_action(app), + _ => {} + } +} + +pub(crate) fn handle_add_marketplace_overlay_key(app: &mut App, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => confirm_add_marketplace_overlay(app), + (KeyCode::Esc, KeyModifiers::NONE) => app.config.overlay = None, + (KeyCode::Left, KeyModifiers::NONE) => { + move_add_marketplace_cursor_left(app); + } + (KeyCode::Right, KeyModifiers::NONE) => { + move_add_marketplace_cursor_right(app); + } + (KeyCode::Home, KeyModifiers::NONE) => set_add_marketplace_cursor(app, 0), + (KeyCode::End, KeyModifiers::NONE) => move_add_marketplace_cursor_to_end(app), + (KeyCode::Backspace, KeyModifiers::NONE) => delete_add_marketplace_before_cursor(app), + (KeyCode::Delete, KeyModifiers::NONE) => delete_add_marketplace_at_cursor(app), + (KeyCode::Char(ch), modifiers) + if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => + { + insert_add_marketplace_char(app, ch); + } + _ => {} + } +} + +fn open_marketplace_overlay(app: &mut App) -> bool { + if selected_add_marketplace_row(app) { + open_add_marketplace_overlay(app) + } else { + open_marketplace_actions_overlay(app) + } +} + +fn open_installed_actions_overlay(app: &mut App) -> bool { + let selected = selected_installed_entry(app).cloned(); + let Some(entry) = selected else { + return false; + }; + + let title = display_label(&entry.id); + let description = installed_overlay_description(app, &entry); + let actions = installed_overlay_actions(app, &entry); + app.config.overlay = + Some(ConfigOverlayState::InstalledPluginActions(InstalledPluginActionOverlayState { + plugin_id: entry.id, + title, + description, + scope: entry.scope, + project_path: entry.project_path, + selected_index: 0, + actions, + })); + true +} + +fn open_plugin_install_overlay(app: &mut App) -> bool { + let selected = selected_marketplace_plugin(app).cloned(); + let Some(entry) = selected else { + return false; + }; + + app.config.overlay = + Some(ConfigOverlayState::PluginInstallActions(PluginInstallOverlayState { + plugin_id: entry.plugin_id, + title: display_label(&entry.name), + description: entry + .description + .unwrap_or_else(|| "Install this plugin into Claude Code.".to_owned()), + selected_index: 0, + actions: vec![ + PluginInstallActionKind::User, + PluginInstallActionKind::Project, + PluginInstallActionKind::Local, + ], + })); + true +} + +fn open_marketplace_actions_overlay(app: &mut App) -> bool { + let selected = selected_marketplace_source(app).cloned(); + let Some(entry) = selected else { + return false; + }; + + app.config.overlay = + Some(ConfigOverlayState::MarketplaceActions(MarketplaceActionsOverlayState { + name: entry.name.clone(), + title: display_label(&entry.name), + description: marketplace_overlay_description(&entry), + selected_index: 0, + actions: vec![MarketplaceActionKind::Update, MarketplaceActionKind::Remove], + })); + true +} + +fn open_add_marketplace_overlay(app: &mut App) -> bool { + app.config.overlay = Some(ConfigOverlayState::AddMarketplace( + AddMarketplaceOverlayState::from_text_input(String::new(), 0), + )); + app.config.last_error = None; + true +} + +fn move_installed_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.installed_plugin_actions_overlay_mut() else { + return; + }; + let len = overlay.actions.len(); + if len == 0 { + overlay.selected_index = 0; + return; + } + let current = overlay.selected_index; + overlay.selected_index = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta.cast_unsigned()).min(len.saturating_sub(1)) + }; +} + +fn move_plugin_install_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.plugin_install_overlay_mut() else { + return; + }; + let len = overlay.actions.len(); + if len == 0 { + overlay.selected_index = 0; + return; + } + let current = overlay.selected_index; + overlay.selected_index = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta.cast_unsigned()).min(len.saturating_sub(1)) + }; +} + +fn move_marketplace_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.marketplace_actions_overlay_mut() else { + return; + }; + let len = overlay.actions.len(); + if len == 0 { + overlay.selected_index = 0; + return; + } + let current = overlay.selected_index; + overlay.selected_index = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta.cast_unsigned()).min(len.saturating_sub(1)) + }; +} + +fn execute_selected_installed_overlay_action(app: &mut App) { + let Some(overlay) = app.config.installed_plugin_actions_overlay().cloned() else { + return; + }; + let Some(action) = overlay.actions.get(overlay.selected_index).copied() else { + return; + }; + + let (cwd_raw, args, status_message) = installed_action_command(app, &overlay, action); + + if tokio::runtime::Handle::try_current().is_err() { + app.config.overlay = None; + app.config.status_message = None; + app.config.last_error = Some("No runtime available for plugin action".to_owned()); + return; + } + + app.config.overlay = None; + app.config.last_error = None; + app.config.status_message = Some(status_message); + app.plugins.loading = true; + app.plugins.last_inventory_refresh_at = None; + app.needs_redraw = true; + let event_tx = app.event_tx.clone(); + let cwd_context = app.cwd_raw.clone(); + let cached_claude_path = app.plugins.claude_path.clone(); + tokio::task::spawn_local(async move { + match cli::run_cli_command_and_refresh(cwd_raw, cached_claude_path, args).await { + Ok((snapshot, claude_path)) => { + let message = + installed_action_success_message(action, &overlay.title, &overlay.scope); + let _ = event_tx.send(ClientEvent::PluginsCliActionSucceeded { + cwd_raw: cwd_context, + result: PluginsCliActionSuccess { snapshot, message, claude_path }, + }); + } + Err(message) => { + let _ = event_tx + .send(ClientEvent::PluginsCliActionFailed { cwd_raw: cwd_context, message }); + } + } + }); +} + +fn execute_selected_plugin_install_action(app: &mut App) { + let Some(overlay) = app.config.plugin_install_overlay().cloned() else { + return; + }; + let Some(action) = overlay.actions.get(overlay.selected_index).copied() else { + return; + }; + + if tokio::runtime::Handle::try_current().is_err() { + app.config.overlay = None; + app.config.status_message = None; + app.config.last_error = Some("No runtime available for plugin action".to_owned()); + return; + } + + let scope = action.scope(); + let args = vec![ + "plugin".to_owned(), + "install".to_owned(), + overlay.plugin_id.clone(), + "--scope".to_owned(), + scope.to_owned(), + ]; + let status_message = match action { + PluginInstallActionKind::User => format!("Installing {} for user scope...", overlay.title), + PluginInstallActionKind::Project => { + format!("Installing {} for project scope...", overlay.title) + } + PluginInstallActionKind::Local => { + format!("Installing {} locally...", overlay.title) + } + }; + + app.config.overlay = None; + app.config.last_error = None; + app.config.status_message = Some(status_message); + app.plugins.loading = true; + app.plugins.last_inventory_refresh_at = None; + app.needs_redraw = true; + let event_tx = app.event_tx.clone(); + let cwd_raw = app.cwd_raw.clone(); + let cwd_context = app.cwd_raw.clone(); + let cached_claude_path = app.plugins.claude_path.clone(); + tokio::task::spawn_local(async move { + match cli::run_cli_command_and_refresh(cwd_raw, cached_claude_path, args).await { + Ok((snapshot, claude_path)) => { + let message = plugin_install_success_message(action, &overlay.title); + let _ = event_tx.send(ClientEvent::PluginsCliActionSucceeded { + cwd_raw: cwd_context, + result: PluginsCliActionSuccess { snapshot, message, claude_path }, + }); + } + Err(message) => { + let _ = event_tx + .send(ClientEvent::PluginsCliActionFailed { cwd_raw: cwd_context, message }); + } + } + }); +} + +fn execute_selected_marketplace_action(app: &mut App) { + let Some(overlay) = app.config.marketplace_actions_overlay().cloned() else { + return; + }; + let Some(action) = overlay.actions.get(overlay.selected_index).copied() else { + return; + }; + + if tokio::runtime::Handle::try_current().is_err() { + app.config.overlay = None; + app.config.status_message = None; + app.config.last_error = Some("No runtime available for marketplace action".to_owned()); + return; + } + + let args = marketplace_action_command(&overlay, action); + let status_message = marketplace_action_status_message(&overlay.title, action); + + app.config.overlay = None; + app.config.last_error = None; + app.config.status_message = Some(status_message); + app.plugins.loading = true; + app.plugins.last_inventory_refresh_at = None; + app.needs_redraw = true; + let event_tx = app.event_tx.clone(); + let cwd_raw = app.cwd_raw.clone(); + let cwd_context = app.cwd_raw.clone(); + let cached_claude_path = app.plugins.claude_path.clone(); + tokio::task::spawn_local(async move { + match cli::run_cli_command_and_refresh(cwd_raw, cached_claude_path, args).await { + Ok((snapshot, claude_path)) => { + let message = marketplace_action_success_message(&overlay.title, action); + let _ = event_tx.send(ClientEvent::PluginsCliActionSucceeded { + cwd_raw: cwd_context, + result: PluginsCliActionSuccess { snapshot, message, claude_path }, + }); + } + Err(message) => { + let _ = event_tx + .send(ClientEvent::PluginsCliActionFailed { cwd_raw: cwd_context, message }); + } + } + }); +} + +fn confirm_add_marketplace_overlay(app: &mut App) { + let Some(overlay) = app.config.add_marketplace_overlay().cloned() else { + return; + }; + let source = overlay.draft.trim().to_owned(); + if source.is_empty() { + app.config.last_error = Some("Marketplace source cannot be empty".to_owned()); + app.config.status_message = None; + return; + } + if tokio::runtime::Handle::try_current().is_err() { + app.config.overlay = None; + app.config.status_message = None; + app.config.last_error = Some("No runtime available for marketplace action".to_owned()); + return; + } + + let args = vec![ + "plugin".to_owned(), + "marketplace".to_owned(), + "add".to_owned(), + source.clone(), + "--scope".to_owned(), + "user".to_owned(), + ]; + + app.config.overlay = None; + app.config.last_error = None; + app.config.status_message = Some(format!("Adding marketplace {source}...")); + app.plugins.loading = true; + app.plugins.last_inventory_refresh_at = None; + app.needs_redraw = true; + let event_tx = app.event_tx.clone(); + let cwd_raw = app.cwd_raw.clone(); + let cwd_context = app.cwd_raw.clone(); + let cached_claude_path = app.plugins.claude_path.clone(); + tokio::task::spawn_local(async move { + match cli::run_cli_command_and_refresh(cwd_raw, cached_claude_path, args).await { + Ok((snapshot, claude_path)) => { + let _ = event_tx.send(ClientEvent::PluginsCliActionSucceeded { + cwd_raw: cwd_context, + result: PluginsCliActionSuccess { + snapshot, + message: format!("Added marketplace {source}"), + claude_path, + }, + }); + } + Err(message) => { + let _ = event_tx + .send(ClientEvent::PluginsCliActionFailed { cwd_raw: cwd_context, message }); + } + } + }); +} + +pub(crate) fn apply_cli_action_success(app: &mut App, result: PluginsCliActionSuccess) { + apply_inventory_refresh_success(app, result.snapshot, result.claude_path); + app.config.last_error = None; + app.config.status_message = Some(result.message); +} + +pub(crate) fn apply_cli_action_failure(app: &mut App, message: String) { + app.plugins.loading = false; + app.config.status_message = None; + app.config.last_error = Some(message); +} + +fn installed_action_command( + app: &App, + overlay: &InstalledPluginActionOverlayState, + action: InstalledPluginActionKind, +) -> (String, Vec, String) { + let cwd_raw = action_cwd(app, overlay); + let plugin_id = overlay.plugin_id.clone(); + let scope = overlay.scope.clone(); + let action_label = display_label(&plugin_id); + match action { + InstalledPluginActionKind::Enable => ( + cwd_raw.clone(), + vec![ + "plugin".to_owned(), + "enable".to_owned(), + plugin_id.clone(), + "--scope".to_owned(), + scope.clone(), + ], + format!("Enabling {action_label}..."), + ), + InstalledPluginActionKind::Disable => ( + cwd_raw.clone(), + vec![ + "plugin".to_owned(), + "disable".to_owned(), + plugin_id.clone(), + "--scope".to_owned(), + scope.clone(), + ], + format!("Disabling {action_label}..."), + ), + InstalledPluginActionKind::Update => ( + cwd_raw.clone(), + vec![ + "plugin".to_owned(), + "update".to_owned(), + plugin_id.clone(), + "--scope".to_owned(), + scope.clone(), + ], + format!("Updating {action_label}..."), + ), + InstalledPluginActionKind::InstallInCurrentProject => ( + app.cwd_raw.clone(), + vec![ + "plugin".to_owned(), + "install".to_owned(), + plugin_id.clone(), + "--scope".to_owned(), + "local".to_owned(), + ], + format!("Installing {action_label} in the current project..."), + ), + InstalledPluginActionKind::Uninstall => ( + cwd_raw, + vec![ + "plugin".to_owned(), + "uninstall".to_owned(), + plugin_id, + "--scope".to_owned(), + scope, + ], + format!("Uninstalling {action_label}..."), + ), + } +} + +fn installed_action_success_message( + action: InstalledPluginActionKind, + title: &str, + scope: &str, +) -> String { + match action { + InstalledPluginActionKind::Enable => format!("Enabled {title} in {scope} scope"), + InstalledPluginActionKind::Disable => format!("Disabled {title} in {scope} scope"), + InstalledPluginActionKind::Update => format!("Updated {title} in {scope} scope"), + InstalledPluginActionKind::InstallInCurrentProject => { + format!("Installed {title} in the current project") + } + InstalledPluginActionKind::Uninstall => format!("Uninstalled {title} from {scope} scope"), + } +} + +fn plugin_install_success_message(action: PluginInstallActionKind, title: &str) -> String { + match action { + PluginInstallActionKind::User => format!("Installed {title} for user scope"), + PluginInstallActionKind::Project => format!("Installed {title} for project scope"), + PluginInstallActionKind::Local => format!("Installed {title} locally"), + } +} + +fn marketplace_action_command( + overlay: &MarketplaceActionsOverlayState, + action: MarketplaceActionKind, +) -> Vec { + match action { + MarketplaceActionKind::Update => vec![ + "plugin".to_owned(), + "marketplace".to_owned(), + "update".to_owned(), + overlay.name.clone(), + ], + MarketplaceActionKind::Remove => vec![ + "plugin".to_owned(), + "marketplace".to_owned(), + "remove".to_owned(), + overlay.name.clone(), + ], + } +} + +fn marketplace_action_status_message(title: &str, action: MarketplaceActionKind) -> String { + match action { + MarketplaceActionKind::Update => format!("Updating {title} marketplace..."), + MarketplaceActionKind::Remove => format!("Removing {title} marketplace..."), + } +} + +fn marketplace_action_success_message(title: &str, action: MarketplaceActionKind) -> String { + match action { + MarketplaceActionKind::Update => format!("Updated {title} marketplace"), + MarketplaceActionKind::Remove => format!("Removed {title} marketplace"), + } +} + +fn action_cwd(app: &App, overlay: &InstalledPluginActionOverlayState) -> String { + match overlay.scope.as_str() { + "local" | "project" => overlay.project_path.clone().unwrap_or_else(|| app.cwd_raw.clone()), + _ => app.cwd_raw.clone(), + } +} + +fn installed_overlay_actions( + app: &App, + entry: &InstalledPluginEntry, +) -> Vec { + let mut actions = Vec::new(); + match entry.scope.as_str() { + "user" | "project" | "local" => { + actions.push(if entry.enabled { + InstalledPluginActionKind::Disable + } else { + InstalledPluginActionKind::Enable + }); + } + _ => {} + } + actions.push(InstalledPluginActionKind::Update); + if can_install_in_current_project(app, entry) { + actions.push(InstalledPluginActionKind::InstallInCurrentProject); + } + actions.push(InstalledPluginActionKind::Uninstall); + actions +} + +fn installed_overlay_description(app: &App, entry: &InstalledPluginEntry) -> String { + if let Some(description) = app + .plugins + .marketplace + .iter() + .find(|candidate| candidate.plugin_id == entry.id) + .and_then(|candidate| candidate.description.as_deref()) + { + return description.to_owned(); + } + + match entry.project_path.as_deref() { + Some(project_path) => format!("Installed in {} scope for {}.", entry.scope, project_path), + None => format!("Installed in {} scope.", entry.scope), + } +} + +fn can_install_in_current_project(app: &App, entry: &InstalledPluginEntry) -> bool { + let current_project = normalize_project_path(&app.cwd_raw); + let selected_project = entry.project_path.as_deref().map(normalize_project_path); + if matches!(entry.scope.as_str(), "local" | "project") + && selected_project.as_deref() == Some(current_project.as_str()) + { + return false; + } + + !app.plugins.installed.iter().any(|candidate| { + candidate.id == entry.id + && matches!(candidate.scope.as_str(), "local" | "project") + && candidate.project_path.as_deref().map(normalize_project_path).as_deref() + == Some(current_project.as_str()) + }) +} + +fn selected_installed_entry(app: &App) -> Option<&InstalledPluginEntry> { + ordered_installed(&app.plugins, &app.cwd_raw).get(app.plugins.installed_selected_index).copied() +} + +fn selected_marketplace_plugin(app: &App) -> Option<&MarketplaceEntry> { + filtered_marketplace_plugins(&app.plugins).get(app.plugins.plugins_selected_index).copied() +} + +fn selected_marketplace_source(app: &App) -> Option<&MarketplaceSourceEntry> { + visible_marketplaces(&app.plugins).get(app.plugins.marketplace_selected_index).copied() +} + +fn selected_add_marketplace_row(app: &App) -> bool { + app.plugins.marketplace_selected_index >= visible_marketplaces(&app.plugins).len() +} + +fn marketplace_row_count(state: &PluginsState) -> usize { + state.marketplaces.len().saturating_add(1) +} + +fn marketplace_overlay_description(entry: &MarketplaceSourceEntry) -> String { + let mut parts = Vec::new(); + if let Some(source) = entry.source.as_deref() { + parts.push(format!("Source: {source}")); + } + if let Some(repo) = entry.repo.as_deref() { + parts.push(format!("Repo: {repo}")); + } + if parts.is_empty() { + "Manage this configured marketplace.".to_owned() + } else { + parts.join("\n") + } +} + +fn normalize_project_path(path: &str) -> String { + path.replace('\\', "/").trim_end_matches('/').to_ascii_lowercase() +} + +fn normalize_single_line_input(text: &str) -> String { + text.replace("\r\n", "\n").replace('\r', "\n").replace('\n', " ") +} + +fn move_add_marketplace_cursor_left(app: &mut App) { + let Some(overlay) = app.config.add_marketplace_overlay_mut() else { + return; + }; + overlay.cursor = overlay.cursor.saturating_sub(1); +} + +fn move_add_marketplace_cursor_right(app: &mut App) { + let Some(overlay) = app.config.add_marketplace_overlay_mut() else { + return; + }; + overlay.cursor = overlay.cursor.saturating_add(1).min(overlay.draft.chars().count()); +} + +fn move_add_marketplace_cursor_to_end(app: &mut App) { + let Some(overlay) = app.config.add_marketplace_overlay_mut() else { + return; + }; + overlay.cursor = overlay.draft.chars().count(); +} + +fn set_add_marketplace_cursor(app: &mut App, cursor: usize) { + let Some(overlay) = app.config.add_marketplace_overlay_mut() else { + return; + }; + overlay.cursor = cursor.min(overlay.draft.chars().count()); +} + +fn insert_add_marketplace_char(app: &mut App, ch: char) { + let Some(overlay) = app.config.add_marketplace_overlay_mut() else { + return; + }; + let byte_index = char_to_byte_index(&overlay.draft, overlay.cursor); + overlay.draft.insert(byte_index, ch); + overlay.cursor += 1; +} + +fn delete_add_marketplace_before_cursor(app: &mut App) { + let Some(overlay) = app.config.add_marketplace_overlay_mut() else { + return; + }; + if overlay.cursor == 0 { + return; + } + let end = char_to_byte_index(&overlay.draft, overlay.cursor); + let start = char_to_byte_index(&overlay.draft, overlay.cursor - 1); + overlay.draft.replace_range(start..end, ""); + overlay.cursor -= 1; +} + +fn delete_add_marketplace_at_cursor(app: &mut App) { + let Some(overlay) = app.config.add_marketplace_overlay_mut() else { + return; + }; + let char_count = overlay.draft.chars().count(); + if overlay.cursor >= char_count { + return; + } + let start = char_to_byte_index(&overlay.draft, overlay.cursor); + let end = char_to_byte_index(&overlay.draft, overlay.cursor + 1); + overlay.draft.replace_range(start..end, ""); +} + +fn char_to_byte_index(text: &str, char_index: usize) -> usize { + text.char_indices().nth(char_index).map_or(text.len(), |(idx, _)| idx) +} + +fn reset_selection_for_active_tab(app: &mut App) { + app.plugins.set_selected_index_for(app.plugins.active_tab, 0); + clamp_selection(app); +} + +fn move_selection(app: &mut App, delta: isize) { + let tab = app.plugins.active_tab; + let len = match tab { + PluginsViewTab::Installed => filtered_installed(&app.plugins).len(), + PluginsViewTab::Plugins => filtered_marketplace_plugins(&app.plugins).len(), + PluginsViewTab::Marketplace => marketplace_row_count(&app.plugins), + }; + if len == 0 { + app.plugins.set_selected_index_for(tab, 0); + return; + } + let current = app.plugins.selected_index_for(tab); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta.cast_unsigned()).min(len.saturating_sub(1)) + }; + app.plugins.set_selected_index_for(tab, next); +} + +fn clamp_index(current: usize, len: usize) -> usize { + if len == 0 { 0 } else { current.min(len.saturating_sub(1)) } +} + +fn installed_entry_matches(entry: &InstalledPluginEntry, query: &str) -> bool { + if query.is_empty() { + return true; + } + let query = query.to_ascii_lowercase(); + entry.id.to_ascii_lowercase().contains(&query) + || entry.scope.to_ascii_lowercase().contains(&query) + || entry + .version + .as_deref() + .is_some_and(|version| version.to_ascii_lowercase().contains(&query)) +} + +fn is_relevant_installed_entry(entry: &InstalledPluginEntry, current_project: &str) -> bool { + match entry.scope.as_str() { + "user" => true, + "local" | "project" => entry + .project_path + .as_deref() + .map(normalize_project_path) + .is_some_and(|project| project == current_project), + _ => false, + } +} + +fn marketplace_plugin_matches(entry: &MarketplaceEntry, query: &str) -> bool { + if query.is_empty() { + return true; + } + let query = query.to_ascii_lowercase(); + entry.plugin_id.to_ascii_lowercase().contains(&query) + || entry.name.to_ascii_lowercase().contains(&query) + || entry + .description + .as_deref() + .is_some_and(|description| description.to_ascii_lowercase().contains(&query)) + || entry + .marketplace_name + .as_deref() + .is_some_and(|marketplace| marketplace.to_ascii_lowercase().contains(&query)) + || entry + .version + .as_deref() + .is_some_and(|version| version.to_ascii_lowercase().contains(&query)) +} + +#[must_use] +pub(crate) const fn search_enabled(tab: PluginsViewTab) -> bool { + !matches!(tab, PluginsViewTab::Marketplace) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn plugins_tabs_wrap_in_both_directions() { + assert_eq!(PluginsViewTab::Installed.prev(), PluginsViewTab::Marketplace); + assert_eq!(PluginsViewTab::Marketplace.next(), PluginsViewTab::Installed); + } + + #[test] + fn recent_inventory_snapshot_skips_refresh() { + let mut app = crate::app::App::test_default(); + app.plugins.active_tab = PluginsViewTab::Installed; + app.plugins.last_inventory_refresh_at = Some(Instant::now()); + + request_inventory_refresh_if_needed(&mut app); + + assert!(!app.plugins.loading); + } + + #[test] + fn display_label_normalizes_plugin_and_marketplace_names() { + assert_eq!( + display_label("frontend-design@claude-plugins-official"), + "Frontend Design From Claude Plugins Official" + ); + assert_eq!(display_label("claude-plugins-official"), "Claude Plugins Official"); + } + + #[test] + fn filtered_marketplace_plugins_match_on_name_description_and_marketplace() { + let state = PluginsState { + plugins_search_query: "official".to_owned(), + marketplace: vec![MarketplaceEntry { + plugin_id: "frontend-design@claude-plugins-official".to_owned(), + name: "frontend-design".to_owned(), + description: Some("Create distinctive interfaces".to_owned()), + marketplace_name: Some("claude-plugins-official".to_owned()), + version: Some("1.0.0".to_owned()), + install_count: Some(42), + source: None, + }], + ..PluginsState::default() + }; + + assert_eq!(filtered_marketplace_plugins(&state).len(), 1); + } + + #[test] + fn installed_and_plugins_search_queries_are_independent() { + let state = PluginsState { + installed_search_query: "installed".to_owned(), + plugins_search_query: "plugins".to_owned(), + ..PluginsState::default() + }; + + assert_eq!(state.search_query_for(PluginsViewTab::Installed), "installed"); + assert_eq!(state.search_query_for(PluginsViewTab::Plugins), "plugins"); + } + + #[test] + fn install_in_current_project_is_available_for_other_project_local_install() { + let mut app = crate::app::App::test_default(); + app.cwd_raw = "C:\\work\\project-b".to_owned(); + let entry = InstalledPluginEntry { + id: "frontend-design@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "local".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: Some("C:\\work\\project-a".to_owned()), + capability: PluginCapability::Skill, + }; + + assert!(can_install_in_current_project(&app, &entry)); + } + + #[test] + fn install_in_current_project_is_hidden_when_already_installed_here() { + let mut app = crate::app::App::test_default(); + app.cwd_raw = "C:\\work\\project-b".to_owned(); + app.plugins.installed.push(InstalledPluginEntry { + id: "frontend-design@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "local".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: Some("C:\\work\\project-b".to_owned()), + capability: PluginCapability::Skill, + }); + let entry = InstalledPluginEntry { + id: "frontend-design@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "local".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: Some("C:\\work\\project-a".to_owned()), + capability: PluginCapability::Skill, + }; + + assert!(!can_install_in_current_project(&app, &entry)); + } + + #[test] + fn ordered_installed_puts_current_project_and_user_entries_first() { + let state = PluginsState { + installed: vec![ + InstalledPluginEntry { + id: "other-local@claude-plugins-official".to_owned(), + version: None, + scope: "local".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: Some("C:\\work\\project-a".to_owned()), + capability: PluginCapability::Skill, + }, + InstalledPluginEntry { + id: "user-plugin@claude-plugins-official".to_owned(), + version: None, + scope: "user".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: None, + capability: PluginCapability::Skill, + }, + InstalledPluginEntry { + id: "current-local@claude-plugins-official".to_owned(), + version: None, + scope: "local".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: Some("C:\\work\\project-b".to_owned()), + capability: PluginCapability::Skill, + }, + ], + ..PluginsState::default() + }; + + let ordered = ordered_installed(&state, "C:\\work\\project-b"); + let ordered_ids = ordered.iter().map(|entry| entry.id.as_str()).collect::>(); + + assert_eq!( + ordered_ids, + vec![ + "user-plugin@claude-plugins-official", + "current-local@claude-plugins-official", + "other-local@claude-plugins-official", + ] + ); + } +} diff --git a/claude-code-rust/src/app/questions.rs b/claude-code-rust/src/app/questions.rs new file mode 100644 index 0000000..9a56879 --- /dev/null +++ b/claude-code-rust/src/app/questions.rs @@ -0,0 +1,635 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::inline_interactions::{ + focus_next_inline_interaction, focused_interaction, focused_interaction_dirty_idx, + get_focused_interaction_tc, invalidate_if_changed, pop_next_valid_interaction_id, +}; +use super::{App, InvalidationLevel, MessageBlock}; +use crate::agent::model; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +fn focused_question(app: &App) -> Option<&crate::app::InlineQuestion> { + focused_interaction(app)?.pending_question.as_ref() +} + +pub(super) fn has_focused_question(app: &App) -> bool { + focused_question(app).is_some() +} + +fn focused_question_is_editing_notes(app: &App) -> bool { + focused_question(app).is_some_and(|question| question.editing_notes) +} + +fn focused_question_option_count(app: &App) -> usize { + focused_question(app).map_or(0, |question| question.prompt.options.len()) +} + +fn is_printable_question_note_modifiers(modifiers: KeyModifiers) -> bool { + let ctrl_alt = + modifiers.contains(KeyModifiers::CONTROL) && modifiers.contains(KeyModifiers::ALT); + !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) || ctrl_alt +} + +fn move_question_option_left(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + { + let next = question.focused_option_index.saturating_sub(1); + if next != question.focused_option_index { + question.focused_option_index = next; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn move_question_option_right(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + && question.focused_option_index + 1 < question.prompt.options.len() + { + question.focused_option_index += 1; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn move_question_option_to_start(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + && question.focused_option_index != 0 + { + question.focused_option_index = 0; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn move_question_option_to_end(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + && let Some(last_idx) = question.prompt.options.len().checked_sub(1) + && question.focused_option_index != last_idx + { + question.focused_option_index = last_idx; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn question_notes_byte_index(notes: &str, cursor: usize) -> usize { + notes.char_indices().nth(cursor).map_or(notes.len(), |(idx, _)| idx) +} + +fn insert_question_note_char(app: &mut App, ch: char) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + { + let idx = question_notes_byte_index(&question.notes, question.notes_cursor); + question.notes.insert(idx, ch); + question.notes_cursor += 1; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn delete_question_note_char_before(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + && question.notes_cursor > 0 + { + let start = question_notes_byte_index(&question.notes, question.notes_cursor - 1); + let end = question_notes_byte_index(&question.notes, question.notes_cursor); + question.notes.replace_range(start..end, ""); + question.notes_cursor -= 1; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn delete_question_note_char_after(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + && question.notes_cursor < question.notes.chars().count() + { + let start = question_notes_byte_index(&question.notes, question.notes_cursor); + let end = question_notes_byte_index(&question.notes, question.notes_cursor + 1); + question.notes.replace_range(start..end, ""); + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn move_question_notes_cursor(app: &mut App, direction: i32) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + { + let max = question.notes.chars().count(); + let next = if direction < 0 { + question.notes_cursor.saturating_sub(1) + } else { + (question.notes_cursor + 1).min(max) + }; + if next != question.notes_cursor { + question.notes_cursor = next; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn move_question_notes_cursor_to_start(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + && question.notes_cursor != 0 + { + question.notes_cursor = 0; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn move_question_notes_cursor_to_end(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + { + let next = question.notes.chars().count(); + if question.notes_cursor != next { + question.notes_cursor = next; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn set_question_notes_editing(app: &mut App, editing_notes: bool) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + && question.editing_notes != editing_notes + { + question.editing_notes = editing_notes; + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn toggle_question_selection(app: &mut App) { + let dirty_idx = focused_interaction_dirty_idx(app); + let mut changed = false; + if let Some(tc) = get_focused_interaction_tc(app) + && let Some(ref mut question) = tc.pending_question + { + let idx = question.focused_option_index; + if question.prompt.multi_select { + if !question.selected_option_indices.insert(idx) { + question.selected_option_indices.remove(&idx); + } + } else { + question.selected_option_indices.clear(); + question.selected_option_indices.insert(idx); + } + tc.mark_tool_call_layout_dirty(); + changed = true; + } + invalidate_if_changed(app, dirty_idx, changed); +} + +fn question_selected_indices(question: &crate::app::InlineQuestion) -> Vec { + if question.prompt.multi_select { + if question.selected_option_indices.is_empty() { + return vec![question.focused_option_index]; + } + return question.selected_option_indices.iter().copied().collect(); + } + vec![question.focused_option_index] +} + +fn question_annotation( + question: &crate::app::InlineQuestion, + selected_indices: &[usize], +) -> Option { + let preview = selected_indices + .iter() + .filter_map(|idx| question.prompt.options.get(*idx)) + .filter_map(|option| option.preview.as_deref()) + .map(str::trim) + .filter(|preview| !preview.is_empty()) + .collect::>() + .join("\n\n"); + let notes = question.notes.trim(); + if preview.is_empty() && notes.is_empty() { + return None; + } + + Some( + model::QuestionAnnotation::new() + .preview((!preview.is_empty()).then_some(preview)) + .notes((!notes.is_empty()).then_some(notes.to_owned())), + ) +} + +fn respond_question(app: &mut App) { + let Some(tool_id) = pop_next_valid_interaction_id(app) else { + return; + }; + + let Some((mi, bi)) = app.tool_call_index.get(&tool_id).copied() else { + return; + }; + let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + else { + return; + }; + let tc = tc.as_mut(); + let mut invalidated = false; + if let Some(pending) = tc.pending_question.take() { + let selected_indices = question_selected_indices(&pending); + let selected_option_ids = selected_indices + .iter() + .filter_map(|idx| pending.prompt.options.get(*idx)) + .map(|option| option.option_id.clone()) + .collect::>(); + let annotation = question_annotation(&pending, &selected_indices); + + if selected_option_ids.is_empty() { + tracing::warn!("question selection had no valid option ids: tool_call_id={tool_id}"); + let _ = pending.response_tx.send(model::RequestQuestionResponse::new( + model::RequestQuestionOutcome::Cancelled, + )); + } else { + tracing::debug!( + "question selection: tool_call_id={} selected_option_ids={:?}", + tool_id, + selected_option_ids + ); + let _ = pending.response_tx.send(model::RequestQuestionResponse::new( + model::RequestQuestionOutcome::Answered( + model::AnsweredQuestionOutcome::new(selected_option_ids).annotation(annotation), + ), + )); + } + tc.mark_tool_call_layout_dirty(); + invalidated = true; + } + if invalidated { + app.sync_render_cache_slot(mi, bi); + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } + + focus_next_inline_interaction(app); +} + +fn respond_question_cancel(app: &mut App) { + let Some(tool_id) = pop_next_valid_interaction_id(app) else { + return; + }; + + let Some((mi, bi)) = app.tool_call_index.get(&tool_id).copied() else { + return; + }; + let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) + else { + return; + }; + let tc = tc.as_mut(); + if let Some(pending) = tc.pending_question.take() { + let _ = pending + .response_tx + .send(model::RequestQuestionResponse::new(model::RequestQuestionOutcome::Cancelled)); + tc.mark_tool_call_layout_dirty(); + app.sync_render_cache_slot(mi, bi); + app.recompute_message_retained_bytes(mi); + app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); + } + + focus_next_inline_interaction(app); +} + +pub(super) fn handle_question_key( + app: &mut App, + key: KeyEvent, + interaction_has_focus: bool, +) -> Option { + if !has_focused_question(app) || !interaction_has_focus { + return None; + } + let option_count = focused_question_option_count(app); + + if focused_question_is_editing_notes(app) { + return match key.code { + KeyCode::Left => { + move_question_notes_cursor(app, -1); + Some(true) + } + KeyCode::Right => { + move_question_notes_cursor(app, 1); + Some(true) + } + KeyCode::Home => { + move_question_notes_cursor_to_start(app); + Some(true) + } + KeyCode::End => { + move_question_notes_cursor_to_end(app); + Some(true) + } + KeyCode::Backspace => { + delete_question_note_char_before(app); + Some(true) + } + KeyCode::Delete => { + delete_question_note_char_after(app); + Some(true) + } + KeyCode::Tab | KeyCode::BackTab => { + set_question_notes_editing(app, false); + Some(true) + } + KeyCode::Enter => { + respond_question(app); + Some(true) + } + KeyCode::Esc => { + respond_question_cancel(app); + Some(true) + } + KeyCode::Up | KeyCode::Down => Some(true), + KeyCode::Char(ch) if is_printable_question_note_modifiers(key.modifiers) => { + insert_question_note_char(app, ch); + Some(true) + } + _ => None, + }; + } + + match key.code { + KeyCode::Left | KeyCode::Up if option_count > 0 => { + move_question_option_left(app); + Some(true) + } + KeyCode::Right | KeyCode::Down if option_count > 0 => { + move_question_option_right(app); + Some(true) + } + KeyCode::Home if option_count > 0 => { + move_question_option_to_start(app); + Some(true) + } + KeyCode::End if option_count > 0 => { + move_question_option_to_end(app); + Some(true) + } + KeyCode::Char(' ') if option_count > 0 => { + toggle_question_selection(app); + Some(true) + } + KeyCode::Tab | KeyCode::BackTab => { + set_question_notes_editing(app, true); + Some(true) + } + KeyCode::Enter if option_count > 0 => { + respond_question(app); + Some(true) + } + KeyCode::Esc => { + respond_question_cancel(app); + Some(true) + } + KeyCode::Backspace | KeyCode::Delete => Some(true), + KeyCode::Char(_) if is_printable_question_note_modifiers(key.modifiers) => Some(true), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ + App, BlockCache, ChatMessage, InlineQuestion, MessageBlock, MessageRole, ToolCallInfo, + }; + use pretty_assertions::assert_eq; + use std::collections::BTreeSet; + use tokio::sync::oneshot; + + fn test_tool_call(id: &str) -> ToolCallInfo { + ToolCallInfo { + id: id.to_owned(), + title: format!("Tool {id}"), + sdk_tool_name: "AskUserQuestion".to_owned(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status: model::ToolCallStatus::InProgress, + content: Vec::new(), + hidden: false, + terminal_id: None, + terminal_command: None, + terminal_output: None, + terminal_output_len: 0, + terminal_bytes_seen: 0, + terminal_snapshot_mode: crate::app::TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + } + } + + fn assistant_tool_msg(tc: ToolCallInfo) -> ChatMessage { + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(tc))], + usage: None, + } + } + + fn add_question( + app: &mut App, + tool_id: &str, + prompt: model::QuestionPrompt, + focused: bool, + ) -> oneshot::Receiver { + let msg_idx = app.messages.len(); + app.messages.push(assistant_tool_msg(test_tool_call(tool_id))); + app.index_tool_call(tool_id.to_owned(), msg_idx, 0); + + let (tx, rx) = oneshot::channel(); + if let Some(MessageBlock::ToolCall(tc)) = + app.messages.get_mut(msg_idx).and_then(|m| m.blocks.get_mut(0)) + { + tc.pending_question = Some(InlineQuestion { + prompt, + response_tx: tx, + focused_option_index: 0, + selected_option_indices: BTreeSet::new(), + notes: String::new(), + notes_cursor: 0, + editing_notes: false, + focused, + question_index: 0, + total_questions: 1, + }); + } + app.pending_interaction_ids.push(tool_id.to_owned()); + rx + } + + #[test] + fn question_prompt_enter_answers_focused_option_with_preview_annotation() { + let mut app = App::test_default(); + let mut rx = add_question( + &mut app, + "question-1", + model::QuestionPrompt::new( + "Choose a target", + "Target", + false, + vec![ + model::QuestionOption::new("question_0", "Staging") + .preview(Some("Deploy to staging first.".to_owned())), + model::QuestionOption::new("question_1", "Production") + .preview(Some("Deploy to production after approval.".to_owned())), + ], + ), + true, + ); + + let consumed_right = + handle_question_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), true); + let consumed_enter = + handle_question_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), true); + + assert_eq!(consumed_right, Some(true)); + assert_eq!(consumed_enter, Some(true)); + assert!(app.pending_interaction_ids.is_empty()); + + let resp = rx.try_recv().expect("question should be answered"); + let model::RequestQuestionOutcome::Answered(answered) = resp.outcome else { + panic!("expected answered question response"); + }; + assert_eq!(answered.selected_option_ids, vec!["question_1"]); + assert_eq!( + answered.annotation.and_then(|annotation| annotation.preview), + Some("Deploy to production after approval.".to_owned()) + ); + } + + #[test] + fn multi_select_question_collects_toggles_and_notes() { + let mut app = App::test_default(); + let mut rx = add_question( + &mut app, + "question-2", + model::QuestionPrompt::new( + "Pick environments", + "Environments", + true, + vec![ + model::QuestionOption::new("question_0", "Staging") + .preview(Some("Deploy to staging first.".to_owned())), + model::QuestionOption::new("question_1", "Production") + .preview(Some("Deploy to production after approval.".to_owned())), + ], + ), + true, + ); + + assert_eq!( + handle_question_key( + &mut app, + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), + true + ), + Some(true) + ); + assert_eq!( + handle_question_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), true), + Some(true) + ); + assert_eq!( + handle_question_key( + &mut app, + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), + true + ), + Some(true) + ); + assert_eq!( + handle_question_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), true), + Some(true) + ); + for ch in ['n', 'o', 't', 'e'] { + assert_eq!( + handle_question_key( + &mut app, + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + true + ), + Some(true) + ); + } + assert_eq!( + handle_question_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), true), + Some(true) + ); + + let resp = rx.try_recv().expect("question should be answered"); + let model::RequestQuestionOutcome::Answered(answered) = resp.outcome else { + panic!("expected answered question response"); + }; + assert_eq!(answered.selected_option_ids, vec!["question_0", "question_1"]); + assert_eq!( + answered.annotation, + Some( + model::QuestionAnnotation::new() + .preview(Some( + "Deploy to staging first.\n\nDeploy to production after approval." + .to_owned(), + )) + .notes(Some("note".to_owned())), + ) + ); + } +} diff --git a/claude-code-rust/src/app/selection.rs b/claude-code-rust/src/app/selection.rs new file mode 100644 index 0000000..1727b51 --- /dev/null +++ b/claude-code-rust/src/app/selection.rs @@ -0,0 +1,492 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::App; +use super::SelectionState; +use unicode_width::UnicodeWidthChar; + +pub(crate) fn normalize_selection( + a: super::SelectionPoint, + b: super::SelectionPoint, +) -> (super::SelectionPoint, super::SelectionPoint) { + if (a.row, a.col) <= (b.row, b.col) { (a, b) } else { (b, a) } +} + +pub(super) fn clear_selection(app: &mut App) { + app.selection = None; + app.rendered_chat_lines.clear(); + app.rendered_input_lines.clear(); +} + +pub(crate) fn selection_text_from_rendered_lines( + lines: &[String], + selection: SelectionState, +) -> String { + if lines.is_empty() { + return String::new(); + } + + let (start, end) = normalize_selection(selection.start, selection.end); + if start.row >= lines.len() { + return String::new(); + } + let last_row = end.row.min(lines.len().saturating_sub(1)); + + let mut out = String::new(); + for row in start.row..=last_row { + let line = lines.get(row).map_or("", String::as_str); + let start_col = if row == start.row { start.col } else { 0 }; + let end_col = if row == end.row { end.col } else { line_display_width(line) }; + out.push_str(&slice_by_display_cols(line, start_col, end_col)); + if row < last_row { + out.push('\n'); + } + } + out +} + +pub(crate) fn slice_by_display_cols(text: &str, start_col: usize, end_col: usize) -> String { + if start_col >= end_col { + return String::new(); + } + + let mut out = String::new(); + let mut col = 0usize; + let mut last_visible_included = false; + + for ch in text.chars() { + let width = display_width(ch); + if width == 0 { + if last_visible_included { + out.push(ch); + } + continue; + } + + let char_start = col; + let char_end = col.saturating_add(width); + let include = char_end > start_col && char_start < end_col; + if include { + out.push(ch); + } + last_visible_included = include; + col = char_end; + } + + out +} + +fn line_display_width(text: &str) -> usize { + text.chars().map(display_width).sum() +} + +fn display_width(ch: char) -> usize { + match ch { + '\t' | '\n' | '\r' => 1, + _ => UnicodeWidthChar::width(ch).unwrap_or(0), + } +} + +#[cfg(test)] +fn slice_by_cols(text: &str, start_col: usize, end_col: usize) -> String { + let mut out = String::new(); + for (i, ch) in text.chars().enumerate() { + if i >= end_col { + break; + } + if i >= start_col { + out.push(ch); + } + } + out +} + +#[cfg(test)] +mod tests { + // ===== + // TESTS: 42 + // ===== + + use super::*; + use crate::app::SelectionPoint; + use pretty_assertions::assert_eq; + + // normalize_selection + + #[test] + fn normalize_already_ordered() { + let a = SelectionPoint { row: 0, col: 2 }; + let b = SelectionPoint { row: 1, col: 5 }; + let (start, end) = normalize_selection(a, b); + assert_eq!(start, a); + assert_eq!(end, b); + } + + #[test] + fn normalize_reversed() { + let a = SelectionPoint { row: 2, col: 3 }; + let b = SelectionPoint { row: 0, col: 1 }; + let (start, end) = normalize_selection(a, b); + assert_eq!(start, b); + assert_eq!(end, a); + } + + #[test] + fn normalize_same_point() { + let p = SelectionPoint { row: 5, col: 10 }; + let (start, end) = normalize_selection(p, p); + assert_eq!(start, p); + assert_eq!(end, p); + } + + // normalize_selection + + #[test] + fn normalize_same_row_different_cols() { + let a = SelectionPoint { row: 3, col: 10 }; + let b = SelectionPoint { row: 3, col: 2 }; + let (start, end) = normalize_selection(a, b); + assert_eq!(start.col, 2); + assert_eq!(end.col, 10); + } + + #[test] + fn normalize_origin() { + let a = SelectionPoint { row: 0, col: 0 }; + let b = SelectionPoint { row: 0, col: 0 }; + let (start, end) = normalize_selection(a, b); + assert_eq!(start, a); + assert_eq!(end, b); + } + + #[test] + fn normalize_same_row_same_col_nonzero() { + let p = SelectionPoint { row: 7, col: 7 }; + let (start, end) = normalize_selection(p, p); + assert_eq!(start, p); + assert_eq!(end, p); + } + + // normalize_selection + + #[test] + fn normalize_large_coordinates() { + let a = SelectionPoint { row: usize::MAX, col: usize::MAX }; + let b = SelectionPoint { row: 0, col: 0 }; + let (start, end) = normalize_selection(a, b); + assert_eq!(start.row, 0); + assert_eq!(end.row, usize::MAX); + } + + /// Row takes priority in ordering: higher row always comes second + /// even if its col is 0 and the other col is MAX. + #[test] + fn normalize_row_priority_over_col() { + let a = SelectionPoint { row: 0, col: usize::MAX }; + let b = SelectionPoint { row: 1, col: 0 }; + let (start, end) = normalize_selection(a, b); + assert_eq!(start, a, "row 0 must come first regardless of col"); + assert_eq!(end, b); + } + + /// Adjacent rows, both at col 0 - order by row. + #[test] + fn normalize_adjacent_rows_col_zero() { + let a = SelectionPoint { row: 5, col: 0 }; + let b = SelectionPoint { row: 4, col: 0 }; + let (start, end) = normalize_selection(a, b); + assert_eq!(start.row, 4); + assert_eq!(end.row, 5); + } + + /// Symmetry: normalize(a, b) and normalize(b, a) produce the same result. + #[test] + fn normalize_symmetry_many_pairs() { + let pairs = [ + (SelectionPoint { row: 0, col: 0 }, SelectionPoint { row: 0, col: 1 }), + (SelectionPoint { row: 3, col: 9 }, SelectionPoint { row: 1, col: 100 }), + (SelectionPoint { row: 100, col: 0 }, SelectionPoint { row: 0, col: 100 }), + (SelectionPoint { row: 42, col: 42 }, SelectionPoint { row: 42, col: 42 }), + ]; + for (a, b) in pairs { + let (s1, e1) = normalize_selection(a, b); + let (s2, e2) = normalize_selection(b, a); + assert_eq!((s1, e1), (s2, e2), "normalize must be symmetric for {a:?} / {b:?}"); + } + } + + /// Idempotence: normalizing an already-normalized pair doesn't change it. + #[test] + fn normalize_idempotent() { + let a = SelectionPoint { row: 10, col: 20 }; + let b = SelectionPoint { row: 3, col: 50 }; + let (s, e) = normalize_selection(a, b); + let (s2, e2) = normalize_selection(s, e); + assert_eq!((s, e), (s2, e2)); + } + + // slice_by_cols + + #[test] + fn slice_ascii_mid() { + assert_eq!(slice_by_cols("hello world", 2, 7), "llo w"); + } + + #[test] + fn slice_full_string() { + assert_eq!(slice_by_cols("abc", 0, 3), "abc"); + } + + #[test] + fn slice_single_char() { + assert_eq!(slice_by_cols("hello", 1, 2), "e"); + } + + #[test] + fn slice_single_char_string_full() { + assert_eq!(slice_by_cols("x", 0, 1), "x"); + } + + // slice_by_cols + + #[test] + fn slice_empty_string() { + assert_eq!(slice_by_cols("", 0, 5), ""); + } + + #[test] + fn slice_empty_string_zero_range() { + assert_eq!(slice_by_cols("", 0, 0), ""); + } + + #[test] + fn slice_start_equals_end() { + assert_eq!(slice_by_cols("hello", 3, 3), ""); + } + + #[test] + fn slice_out_of_bounds() { + assert_eq!(slice_by_cols("hi", 0, 100), "hi"); + } + + #[test] + fn slice_start_beyond_string() { + assert_eq!(slice_by_cols("hi", 50, 100), ""); + } + + /// `start_col` > `end_col` -- no guard in the function, loop condition + /// `i >= end_col` triggers immediately, so result is empty. + #[test] + fn slice_start_greater_than_end() { + assert_eq!(slice_by_cols("hello world", 7, 2), ""); + } + + /// Tab character is 1 char (col), not 4 or 8. + #[test] + fn slice_tab_chars() { + let s = "a\tb\tc"; + // chars: a(0) \t(1) b(2) \t(3) c(4) + assert_eq!(slice_by_cols(s, 0, 2), "a\t"); + assert_eq!(slice_by_cols(s, 1, 4), "\tb\t"); + } + + /// Newline embedded in a "line" - treated as one char. + #[test] + fn slice_with_embedded_newline() { + let s = "ab\ncd"; + // a(0) b(1) \n(2) c(3) d(4) + assert_eq!(slice_by_cols(s, 1, 4), "b\nc"); + } + + /// Carriage return embedded in a "line". + #[test] + fn slice_with_embedded_cr() { + let s = "ab\rcd"; + assert_eq!(slice_by_cols(s, 0, 5), "ab\rcd"); + } + + /// Null byte is a valid char. + #[test] + fn slice_with_null_byte() { + let s = "a\0b"; + assert_eq!(slice_by_cols(s, 0, 3), "a\0b"); + assert_eq!(slice_by_cols(s, 1, 2), "\0"); + } + + // slice_by_cols + + #[test] + fn slice_unicode_emoji() { + let s = "a\u{1F600}b\u{1F600}c"; + // chars: a(0), emoji(1), b(2), emoji(3), c(4) + assert_eq!(slice_by_cols(s, 1, 4), "\u{1F600}b\u{1F600}"); + } + + #[test] + fn slice_cjk_chars() { + let s = "\u{4F60}\u{597D}\u{4E16}\u{754C}"; // ni hao shi jie + assert_eq!(slice_by_cols(s, 1, 3), "\u{597D}\u{4E16}"); + } + + #[test] + fn slice_mixed_unicode_and_ascii() { + let s = "hi\u{1F600}world"; + // h(0), i(1), emoji(2), w(3), o(4), r(5), l(6), d(7) + assert_eq!(slice_by_cols(s, 0, 3), "hi\u{1F600}"); + assert_eq!(slice_by_cols(s, 3, 8), "world"); + } + + /// Combining diacritical mark: e + combining acute = 2 chars, 1 glyph. + /// Slicing between them splits the glyph - this is the char-based reality. + #[test] + fn slice_combining_diacritical_splits_glyph() { + let s = "e\u{0301}x"; // e + combining acute + x + // chars: e(0), \u{0301}(1), x(2) + assert_eq!(slice_by_cols(s, 0, 1), "e"); // bare e, no accent + assert_eq!(slice_by_cols(s, 1, 2), "\u{0301}"); // orphan combining mark + assert_eq!(slice_by_cols(s, 0, 2), "e\u{0301}"); // full glyph + assert_eq!(slice_by_cols(s, 0, 3), "e\u{0301}x"); // everything + } + + /// Multiple combining marks stacked on one base: a + ring + macron. + #[test] + fn slice_stacked_combining_marks() { + let s = "a\u{030A}\u{0304}z"; // a + combining ring above + combining macron + z + // chars: a(0) ring(1) macron(2) z(3) + assert_eq!(slice_by_cols(s, 0, 3), "a\u{030A}\u{0304}"); + assert_eq!(slice_by_cols(s, 1, 3), "\u{030A}\u{0304}"); // orphaned marks + assert_eq!(slice_by_cols(s, 3, 4), "z"); + } + + /// ZWJ sequence (family emoji): multiple codepoints, one visual glyph. + /// char-based slicing will split it. + #[test] + fn slice_zwj_family_emoji() { + // man + ZWJ + woman + ZWJ + girl = 5 chars, 1 visible emoji + let s = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + assert_eq!(s.chars().count(), 5); + // Slicing first 2 chars gives man + ZWJ (broken glyph) + assert_eq!(slice_by_cols(s, 0, 2), "\u{1F468}\u{200D}"); + // Full sequence + assert_eq!(slice_by_cols(s, 0, 5), s); + } + + /// Flag emoji: two regional indicator chars = 1 flag. + #[test] + fn slice_flag_emoji_splits() { + let flag = "\u{1F1FA}\u{1F1F8}"; // US flag = 2 chars + assert_eq!(flag.chars().count(), 2); + assert_eq!(slice_by_cols(flag, 0, 1), "\u{1F1FA}"); // half a flag + assert_eq!(slice_by_cols(flag, 0, 2), flag); // whole flag + assert_eq!(slice_by_cols(flag, 1, 2), "\u{1F1F8}"); // other half + } + + /// Arabic RTL text - chars are chars regardless of display direction. + #[test] + fn slice_arabic_rtl() { + let s = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; // mrhba + assert_eq!(s.chars().count(), 5); + assert_eq!(slice_by_cols(s, 1, 4), "\u{0631}\u{062D}\u{0628}"); + } + + /// All-emoji string. + #[test] + fn slice_all_emoji() { + let s = "\u{1F600}\u{1F601}\u{1F602}\u{1F603}\u{1F604}"; + assert_eq!(slice_by_cols(s, 2, 4), "\u{1F602}\u{1F603}"); + assert_eq!(slice_by_cols(s, 0, 5), s); + } + + /// Stress test: 10K character string, slice a window in the middle. + #[test] + fn slice_stress_10k_chars() { + let s: String = (0..10_000).map(|i| if i % 2 == 0 { 'a' } else { 'b' }).collect(); + let sliced = slice_by_cols(&s, 4000, 4010); + assert_eq!(sliced.len(), 10); + assert_eq!(sliced, "ababababab"); + } + + /// Stress test: 10K emoji string. + #[test] + fn slice_stress_10k_emoji() { + let s: String = "\u{1F600}".repeat(10_000); + let sliced = slice_by_cols(&s, 9990, 10_000); + assert_eq!(sliced.chars().count(), 10); + } + + /// Slice exactly the last character. + #[test] + fn slice_last_char_only() { + assert_eq!(slice_by_cols("abcdef", 5, 6), "f"); + } + + /// Slice exactly the first character. + #[test] + fn slice_first_char_only() { + assert_eq!(slice_by_cols("abcdef", 0, 1), "a"); + } + + /// Variation selector: base + VS16 = 2 chars. + #[test] + fn slice_variation_selector() { + let s = "\u{2764}\u{FE0F}x"; // red heart emoji (heart + VS16) + x + assert_eq!(s.chars().count(), 3); + assert_eq!(slice_by_cols(s, 0, 2), "\u{2764}\u{FE0F}"); + assert_eq!(slice_by_cols(s, 2, 3), "x"); + } + + /// Mixed script: Latin, CJK, emoji, Arabic all in one string. + #[test] + fn slice_mixed_scripts() { + let s = "Hi\u{4F60}\u{1F600}\u{0645}!"; + // H(0) i(1) ni(2) emoji(3) meem(4) !(5) + assert_eq!(s.chars().count(), 6); + assert_eq!(slice_by_cols(s, 0, 6), s); + assert_eq!(slice_by_cols(s, 2, 5), "\u{4F60}\u{1F600}\u{0645}"); + } + + /// Consecutive zero-range slices always return empty. + #[test] + fn slice_zero_width_at_every_position() { + let s = "hello"; + for i in 0..=5 { + assert_eq!(slice_by_cols(s, i, i), "", "zero-width at col {i}"); + } + } + + /// Sliding window: every 3-char window of "abcde". + #[test] + fn slice_sliding_window() { + let s = "abcde"; + let windows: Vec = (0..3).map(|i| slice_by_cols(s, i, i + 3)).collect(); + assert_eq!(windows, vec!["abc", "bcd", "cde"]); + } + + #[test] + fn slice_by_display_cols_handles_wide_unicode_cells() { + let s = "a😀b"; + assert_eq!(slice_by_display_cols(s, 0, 1), "a"); + assert_eq!(slice_by_display_cols(s, 1, 3), "😀"); + assert_eq!(slice_by_display_cols(s, 2, 4), "😀b"); + } + + #[test] + fn slice_by_display_cols_preserves_combining_suffix_for_selected_base() { + let s = "e\u{0301}x"; + assert_eq!(slice_by_display_cols(s, 0, 1), "e\u{0301}"); + assert_eq!(slice_by_display_cols(s, 1, 2), "x"); + } + + #[test] + fn selection_text_uses_display_columns_for_chat_lines() { + let lines = vec!["a😀b".to_owned()]; + let selection = SelectionState { + kind: crate::app::SelectionKind::Chat, + start: SelectionPoint { row: 0, col: 1 }, + end: SelectionPoint { row: 0, col: 3 }, + dragging: false, + }; + + assert_eq!(selection_text_from_rendered_lines(&lines, selection), "😀"); + } +} diff --git a/claude-code-rust/src/app/service_status_check.rs b/claude-code-rust/src/app/service_status_check.rs new file mode 100644 index 0000000..843e341 --- /dev/null +++ b/claude-code-rust/src/app/service_status_check.rs @@ -0,0 +1,226 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::App; +use crate::agent::events::{ClientEvent, ServiceStatusSeverity}; +use serde::Deserialize; +use std::time::Duration; + +const SERVICE_STATUS_TIMEOUT: Duration = Duration::from_secs(4); +const STATUSPAGE_SUMMARY_URL: &str = "https://status.claude.com/api/v2/summary.json"; + +/// Component names we care about. "Claude Code" is the primary component; +/// "Claude API" is included because Claude Code depends on it. +const RELEVANT_COMPONENTS: &[&str] = &["Claude Code", "Claude API (api.anthropic.com)"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServiceStatusIssue { + severity: ServiceStatusSeverity, + message: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct SummaryResponse { + components: Vec, + incidents: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct Component { + name: String, + status: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct Incident { + name: String, + components: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct IncidentComponent { + name: String, +} + +pub fn start_service_status_check(app: &App) { + let event_tx = app.event_tx.clone(); + + tokio::task::spawn_local(async move { + let Some(issue) = resolve_service_status_issue().await else { + return; + }; + let _ = event_tx + .send(ClientEvent::ServiceStatus { severity: issue.severity, message: issue.message }); + }); +} + +async fn resolve_service_status_issue() -> Option { + let client = reqwest::Client::builder().timeout(SERVICE_STATUS_TIMEOUT).build().ok()?; + let response = client.get(STATUSPAGE_SUMMARY_URL).send().await.ok()?; + if !response.status().is_success() { + tracing::debug!("service-status request failed with status {}", response.status()); + return None; + } + + let payload = response.json::().await.ok()?; + classify_summary(&payload) +} + +fn is_relevant_component(name: &str) -> bool { + RELEVANT_COMPONENTS.contains(&name) +} + +fn classify_component_status(status: &str) -> Option { + match status { + "operational" | "under_maintenance" => None, + "major_outage" => Some(ServiceStatusSeverity::Error), + // degraded_performance, partial_outage, or unknown + _ => Some(ServiceStatusSeverity::Warning), + } +} + +fn classify_summary(summary: &SummaryResponse) -> Option { + // Check if any relevant component is degraded + let worst_severity = summary + .components + .iter() + .filter(|c| is_relevant_component(&c.name)) + .filter_map(|c| classify_component_status(&c.status)) + .max_by_key(|s| match s { + ServiceStatusSeverity::Warning => 0, + ServiceStatusSeverity::Error => 1, + }); + + let severity = worst_severity?; + + // Find incidents that affect our relevant components for a better message + let relevant_incident = summary + .incidents + .iter() + .find(|incident| incident.components.iter().any(|c| is_relevant_component(&c.name))); + + let message = if let Some(incident) = relevant_incident { + format!("Claude Code status: {}.", incident.name.trim()) + } else { + "Claude Code status indicates a service disruption.".to_owned() + }; + + Some(ServiceStatusIssue { severity, message }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn component(name: &str, status: &str) -> Component { + Component { name: name.to_owned(), status: status.to_owned() } + } + + fn incident(name: &str, component_names: &[&str]) -> Incident { + Incident { + name: name.to_owned(), + components: component_names + .iter() + .map(|n| IncidentComponent { name: n.to_string() }) + .collect(), + } + } + + fn summary(components: Vec, incidents: Vec) -> SummaryResponse { + SummaryResponse { components, incidents } + } + + #[test] + fn all_operational_is_healthy() { + let s = summary( + vec![component("Claude Code", "operational"), component("claude.ai", "operational")], + vec![], + ); + assert!(classify_summary(&s).is_none()); + } + + #[test] + fn only_unrelated_component_degraded_is_healthy() { + let s = summary( + vec![ + component("Claude Code", "operational"), + component("claude.ai", "degraded_performance"), + component("Claude for Government", "major_outage"), + ], + vec![], + ); + assert!(classify_summary(&s).is_none()); + } + + #[test] + fn claude_code_degraded_is_warning() { + let s = summary(vec![component("Claude Code", "degraded_performance")], vec![]); + let issue = classify_summary(&s).expect("expected issue"); + assert_eq!(issue.severity, ServiceStatusSeverity::Warning); + } + + #[test] + fn claude_code_major_outage_is_error() { + let s = summary(vec![component("Claude Code", "major_outage")], vec![]); + let issue = classify_summary(&s).expect("expected issue"); + assert_eq!(issue.severity, ServiceStatusSeverity::Error); + } + + #[test] + fn claude_api_degraded_triggers_warning() { + let s = summary( + vec![ + component("Claude Code", "operational"), + component("Claude API (api.anthropic.com)", "partial_outage"), + ], + vec![], + ); + let issue = classify_summary(&s).expect("expected issue"); + assert_eq!(issue.severity, ServiceStatusSeverity::Warning); + } + + #[test] + fn worst_severity_wins() { + let s = summary( + vec![ + component("Claude Code", "degraded_performance"), + component("Claude API (api.anthropic.com)", "major_outage"), + ], + vec![], + ); + let issue = classify_summary(&s).expect("expected issue"); + assert_eq!(issue.severity, ServiceStatusSeverity::Error); + } + + #[test] + fn uses_incident_name_in_message() { + let s = summary( + vec![component("Claude Code", "degraded_performance")], + vec![incident("Elevated errors on Claude Opus 4", &["Claude Code", "claude.ai"])], + ); + let issue = classify_summary(&s).expect("expected issue"); + assert_eq!(issue.message, "Claude Code status: Elevated errors on Claude Opus 4."); + } + + #[test] + fn fallback_message_without_relevant_incident() { + let s = summary( + vec![component("Claude Code", "partial_outage")], + vec![incident("API issue", &["claude.ai"])], + ); + let issue = classify_summary(&s).expect("expected issue"); + assert_eq!(issue.message, "Claude Code status indicates a service disruption."); + } + + #[test] + fn ignores_irrelevant_incident() { + let s = summary( + vec![ + component("Claude Code", "operational"), + component("claude.ai", "degraded_performance"), + ], + vec![incident("claude.ai degraded", &["claude.ai"])], + ); + assert!(classify_summary(&s).is_none()); + } +} diff --git a/claude-code-rust/src/app/slash/candidates.rs b/claude-code-rust/src/app/slash/candidates.rs new file mode 100644 index 0000000..60acdda --- /dev/null +++ b/claude-code-rust/src/app/slash/candidates.rs @@ -0,0 +1,347 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Slash command candidate detection, filtering, and building. + +use super::{ + MAX_CANDIDATES, SlashCandidate, SlashContext, SlashDetection, SlashState, normalize_slash_name, +}; +use crate::app::App; +use crate::app::dialog::DialogState; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) fn detect_argument_at_cursor( + chars: &[char], + mut idx: usize, + cursor_col: usize, +) -> Option<(usize, usize, usize)> { + if cursor_col > chars.len() { + return None; + } + + let mut arg_index = 0usize; + loop { + while idx < chars.len() && chars[idx].is_whitespace() { + if cursor_col == idx { + return Some((arg_index, cursor_col, cursor_col)); + } + idx += 1; + } + + if idx >= chars.len() { + if cursor_col >= idx { + return Some((arg_index, cursor_col, cursor_col)); + } + return None; + } + + let token_start = idx; + while idx < chars.len() && !chars[idx].is_whitespace() { + idx += 1; + } + let token_end = idx; + if (token_start..=token_end).contains(&cursor_col) { + return Some((arg_index, token_start, token_end)); + } + arg_index += 1; + } +} + +pub(super) fn detect_slash_at_cursor( + lines: &[String], + cursor_row: usize, + cursor_col: usize, +) -> Option { + let line = lines.get(cursor_row)?; + let first_non_ws = line.find(|c: char| !c.is_whitespace())?; + let chars: Vec = line.chars().collect(); + if chars.get(first_non_ws).copied() != Some('/') { + return None; + } + + let token_start = first_non_ws; + let token_end = + (token_start + 1..chars.len()).find(|&i| chars[i].is_whitespace()).unwrap_or(chars.len()); + + if cursor_col <= token_start || cursor_col > chars.len() { + return None; + } + + if cursor_col <= token_end { + let query: String = chars[token_start + 1..cursor_col].iter().collect(); + if query.chars().any(char::is_whitespace) { + return None; + } + return Some(SlashDetection { + trigger_row: cursor_row, + trigger_col: token_start, + query, + context: SlashContext::CommandName, + }); + } + + let command: String = chars[token_start..token_end].iter().collect(); + let (arg_index, token_start, token_end) = + detect_argument_at_cursor(&chars, token_end, cursor_col)?; + let query: String = chars[token_start..cursor_col.min(token_end)].iter().collect(); + + Some(SlashDetection { + trigger_row: cursor_row, + trigger_col: token_start, + query, + context: SlashContext::Argument { + command, + arg_index, + token_range: (token_start, token_end), + }, + }) +} + +fn advertised_commands(app: &App) -> Vec { + app.available_commands.iter().map(|cmd| normalize_slash_name(&cmd.name)).collect() +} + +pub(super) fn find_advertised_command<'a>( + app: &'a App, + command_name: &str, +) -> Option<&'a crate::agent::model::AvailableCommand> { + app.available_commands.iter().find(|cmd| normalize_slash_name(&cmd.name) == command_name) +} + +fn is_builtin_variable_input_command(command_name: &str) -> bool { + matches!(command_name, "/mode" | "/model" | "/resume") +} + +pub(super) fn is_variable_input_command(app: &App, command_name: &str) -> bool { + if is_builtin_variable_input_command(command_name) { + return true; + } + + find_advertised_command(app, command_name) + .and_then(|cmd| cmd.input_hint.as_ref()) + .is_some_and(|hint| !hint.trim().is_empty()) +} + +pub(super) fn supported_command_candidates(app: &App) -> Vec { + use std::collections::BTreeMap; + + let mut by_name: BTreeMap = BTreeMap::new(); + by_name.insert("/cancel".into(), "Cancel active turn".into()); + by_name.insert("/compact".into(), "Compact session context".into()); + by_name.insert("/config".into(), "Open settings".into()); + by_name.insert("/login".into(), "Authenticate with Claude".into()); + by_name.insert("/logout".into(), "Sign out of Claude".into()); + by_name.insert("/mcp".into(), "Open MCP".into()); + by_name.insert("/mode".into(), "Set session mode".into()); + by_name.insert("/model".into(), "Set session model".into()); + by_name.insert("/new-session".into(), "Start a fresh session".into()); + by_name.insert("/resume".into(), "Resume a session by ID".into()); + by_name.insert("/plugins".into(), "Open plugins".into()); + by_name.insert("/status".into(), "Show session status".into()); + by_name.insert("/usage".into(), "Open usage".into()); + + for cmd in &app.available_commands { + let name = normalize_slash_name(&cmd.name); + by_name.entry(name).or_insert_with(|| cmd.description.clone()); + } + + by_name + .into_iter() + .map(|(name, description)| SlashCandidate { + insert_value: name.clone(), + primary: name, + secondary: if description.trim().is_empty() { None } else { Some(description) }, + }) + .collect() +} + +pub(super) fn filter_command_candidates( + candidates: &[SlashCandidate], + query: &str, +) -> Vec { + if query.is_empty() { + return candidates.iter().take(MAX_CANDIDATES).cloned().collect(); + } + + let query_lower = query.to_lowercase(); + candidates + .iter() + .filter(|candidate| { + let body = candidate.primary.strip_prefix('/').unwrap_or(&candidate.primary); + body.to_lowercase().contains(&query_lower) + }) + .take(MAX_CANDIDATES) + .cloned() + .collect() +} + +fn candidate_matches(candidate: &SlashCandidate, query_lower: &str) -> bool { + candidate.primary.to_lowercase().contains(query_lower) + || candidate.insert_value.to_lowercase().contains(query_lower) + || candidate + .secondary + .as_ref() + .is_some_and(|secondary| secondary.to_lowercase().contains(query_lower)) +} + +pub(super) fn filter_argument_candidates( + candidates: &[SlashCandidate], + query: &str, +) -> Vec { + if query.is_empty() { + return candidates.iter().take(MAX_CANDIDATES).cloned().collect(); + } + + let query_lower = query.to_lowercase(); + candidates + .iter() + .filter(|candidate| candidate_matches(candidate, &query_lower)) + .take(MAX_CANDIDATES) + .cloned() + .collect() +} + +fn now_epoch_seconds() -> i64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX), + Err(_) => 0, + } +} + +fn format_relative_age(epoch_seconds: i64) -> String { + let now_seconds = now_epoch_seconds(); + let delta_seconds = if now_seconds >= epoch_seconds { + now_seconds - epoch_seconds + } else { + epoch_seconds - now_seconds + }; + + if delta_seconds < 5 * 60 { + return "<5m".to_owned(); + } + if delta_seconds < 60 * 60 { + return format!("{}m", delta_seconds / 60); + } + if delta_seconds < 24 * 60 * 60 { + return format!("{}h", delta_seconds / (60 * 60)); + } + + let total_hours = delta_seconds / (60 * 60); + let days = total_hours / 24; + let hours = total_hours % 24; + format!("{days}d {hours}h") +} + +fn session_age_label(last_modified_ms: Option) -> String { + let Some(last_modified_ms) = last_modified_ms else { + return "--".to_owned(); + }; + let epoch = i64::try_from(last_modified_ms / 1_000).ok(); + let Some(epoch) = epoch else { + return "--".to_owned(); + }; + format_relative_age(epoch) +} + +pub(super) fn argument_candidates( + app: &App, + command_name: &str, + arg_index: usize, +) -> Vec { + if arg_index > 0 { + return Vec::new(); + } + + match command_name { + "/resume" => app + .recent_sessions + .iter() + .map(|session| { + let summary = session.summary.trim(); + let summary = if summary.is_empty() { "(no summary)" } else { summary }; + let age = session_age_label(Some(session.last_modified_ms)); + SlashCandidate { + insert_value: session.session_id.clone(), + primary: format!("{age} - {summary}"), + secondary: Some(session.session_id.clone()), + } + }) + .collect(), + "/mode" => app + .mode + .as_ref() + .map(|mode| { + mode.available_modes + .iter() + .map(|entry| SlashCandidate { + insert_value: entry.id.clone(), + primary: entry.name.clone(), + secondary: Some(entry.id.clone()), + }) + .collect() + }) + .unwrap_or_default(), + "/model" => app + .available_models + .iter() + .map(|model| SlashCandidate { + insert_value: model.id.clone(), + primary: model.display_name.clone(), + secondary: model + .description + .clone() + .or_else(|| (model.display_name != model.id).then(|| model.id.clone())), + }) + .collect(), + _ => Vec::new(), + } +} + +pub(super) fn build_slash_state(app: &App) -> Option { + let detection = + detect_slash_at_cursor(app.input.lines(), app.input.cursor_row(), app.input.cursor_col())?; + + let candidates = match &detection.context { + SlashContext::CommandName => { + filter_command_candidates(&supported_command_candidates(app), &detection.query) + } + SlashContext::Argument { command, arg_index, .. } => { + if !is_variable_input_command(app, command) { + return None; + } + filter_argument_candidates( + &argument_candidates(app, command, *arg_index), + &detection.query, + ) + } + }; + if candidates.is_empty() { + return None; + } + + Some(SlashState { + trigger_row: detection.trigger_row, + trigger_col: detection.trigger_col, + query: detection.query, + context: detection.context, + candidates, + dialog: DialogState::default(), + }) +} + +pub fn is_supported_command(app: &App, command_name: &str) -> bool { + matches!( + command_name, + "/cancel" + | "/compact" + | "/config" + | "/mcp" + | "/mode" + | "/model" + | "/new-session" + | "/resume" + | "/plugins" + | "/status" + | "/usage" + ) || advertised_commands(app).iter().any(|c| c == command_name) +} diff --git a/claude-code-rust/src/app/slash/executors.rs b/claude-code-rust/src/app/slash/executors.rs new file mode 100644 index 0000000..7782599 --- /dev/null +++ b/claude-code-rust/src/app/slash/executors.rs @@ -0,0 +1,450 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Slash command executors: dispatching parsed commands to their handler functions. + +use super::{ + parse, push_system_message, push_user_message, require_active_session, require_connection, + set_command_pending, +}; +use crate::agent::events::ClientEvent; +use crate::app::connect::{SessionStartReason, resume_session, start_new_session}; +use crate::app::events::push_system_message_with_severity; +use crate::app::{App, AppStatus, CancelOrigin, SystemSeverity}; + +/// Handle slash command submission. +/// +/// Returns `true` if the slash input was fully handled and should not be sent as a prompt. +/// Returns `false` when the input should continue through the normal prompt path. +pub fn try_handle_submit(app: &mut App, text: &str) -> bool { + let Some(parsed) = parse(text) else { + return false; + }; + + match parsed.name { + "/cancel" => handle_cancel_submit(app), + "/compact" => handle_compact_submit(app, &parsed.args), + "/config" => handle_config_submit(app, &parsed.args), + "/mcp" => handle_mcp_submit(app, &parsed.args), + "/plugins" => handle_plugins_submit(app, &parsed.args), + "/status" => handle_status_submit(app, &parsed.args), + "/usage" => handle_usage_submit(app, &parsed.args), + "/login" => handle_login_submit(app, &parsed.args), + "/logout" => handle_logout_submit(app, &parsed.args), + "/mode" => handle_mode_submit(app, &parsed.args), + "/model" => handle_model_submit(app, &parsed.args), + "/new-session" => handle_new_session_submit(app, &parsed.args), + "/resume" => handle_resume_submit(app, &parsed.args), + _ => handle_unknown_submit(app, parsed.name), + } +} + +fn handle_cancel_submit(app: &mut App) -> bool { + if !matches!(app.status, AppStatus::Thinking | AppStatus::Running) { + return true; + } + if let Err(message) = crate::app::input_submit::request_cancel(app, CancelOrigin::Manual) { + push_system_message(app, format!("Failed to run /cancel: {message}")); + } + true +} + +fn handle_compact_submit(app: &mut App, args: &[&str]) -> bool { + if !args.is_empty() { + push_system_message(app, "Usage: /compact"); + return true; + } + if require_active_session( + app, + "Cannot compact: not connected yet.", + "Cannot compact: no active session.", + ) + .is_none() + { + return true; + } + + app.is_compacting = true; + false +} + +fn handle_config_submit(app: &mut App, args: &[&str]) -> bool { + if !args.is_empty() { + push_system_message(app, "Usage: /config"); + return true; + } + + if let Err(err) = crate::app::config::open(app) { + push_system_message(app, format!("Failed to open settings: {err}")); + } + true +} + +fn handle_plugins_submit(app: &mut App, args: &[&str]) -> bool { + let _ = args; + + if let Err(err) = crate::app::config::open(app) { + push_system_message(app, format!("Failed to open plugins: {err}")); + return true; + } + crate::app::config::activate_tab(app, crate::app::ConfigTab::Plugins); + true +} + +fn handle_mcp_submit(app: &mut App, args: &[&str]) -> bool { + if !args.is_empty() { + push_system_message(app, "Usage: /mcp"); + return true; + } + + if let Err(err) = crate::app::config::open(app) { + push_system_message(app, format!("Failed to open MCP: {err}")); + return true; + } + crate::app::config::activate_tab(app, crate::app::ConfigTab::Mcp); + true +} + +fn handle_status_submit(app: &mut App, args: &[&str]) -> bool { + if !args.is_empty() { + push_system_message(app, "Usage: /status"); + return true; + } + + if let Err(err) = crate::app::config::open(app) { + push_system_message(app, format!("Failed to open status: {err}")); + return true; + } + crate::app::config::activate_tab(app, crate::app::ConfigTab::Status); + true +} + +fn handle_usage_submit(app: &mut App, args: &[&str]) -> bool { + if !args.is_empty() { + push_system_message(app, "Usage: /usage"); + return true; + } + + if let Err(err) = crate::app::config::open(app) { + push_system_message(app, format!("Failed to open usage: {err}")); + return true; + } + crate::app::config::activate_tab(app, crate::app::ConfigTab::Usage); + true +} + +fn handle_login_submit(app: &mut App, args: &[&str]) -> bool { + if !args.is_empty() { + push_system_message(app, "Usage: /login"); + return true; + } + + push_user_message(app, "/login"); + tracing::debug!("Handling /login command"); + + if crate::app::auth::has_credentials() { + push_system_message_with_severity( + app, + Some(SystemSeverity::Info), + "Already authenticated. Use /logout first to re-authenticate.", + ); + return true; + } + + let Some(claude_path) = resolve_claude_cli(app, "login") else { + return true; + }; + + set_command_pending(app, "Authenticating...", None); + + let tx = app.event_tx.clone(); + let conn = app.conn.clone(); + tokio::task::spawn_local(async move { + tracing::debug!("Suspending TUI for claude auth login"); + crate::app::suspend_terminal(); + + let result = tokio::process::Command::new(&claude_path) + .args(["auth", "login"]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await; + + crate::app::resume_terminal(); + + match result { + Ok(status) => { + tracing::debug!( + success = status.success(), + code = ?status.code(), + "claude auth login exited" + ); + if status.success() { + if !crate::app::auth::has_credentials() { + let _ = tx.send(ClientEvent::SlashCommandError( + "Login exited successfully but no credentials were saved. \ + Try /login again or run `claude auth login` in another terminal." + .to_owned(), + )); + return; + } + if let Some(conn) = conn { + let _ = tx.send(ClientEvent::AuthCompleted { conn }); + } else { + let _ = tx.send(ClientEvent::SlashCommandError( + "Login succeeded but no connection available to start a session." + .to_owned(), + )); + } + } else { + let _ = tx.send(ClientEvent::SlashCommandError(format!( + "/login failed (exit code: {})", + status.code().map_or("unknown".to_owned(), |c| c.to_string()) + ))); + } + } + Err(e) => { + let _ = tx.send(ClientEvent::SlashCommandError(format!( + "Failed to run claude auth login: {e}" + ))); + } + } + }); + true +} + +fn handle_logout_submit(app: &mut App, args: &[&str]) -> bool { + if !args.is_empty() { + push_system_message(app, "Usage: /logout"); + return true; + } + + push_user_message(app, "/logout"); + tracing::debug!("Handling /logout command"); + + if !crate::app::auth::has_credentials() { + push_system_message_with_severity( + app, + Some(SystemSeverity::Info), + "Not currently authenticated. Nothing to log out from.", + ); + return true; + } + + let Some(claude_path) = resolve_claude_cli(app, "logout") else { + return true; + }; + + set_command_pending(app, "Signing out...", None); + + let tx = app.event_tx.clone(); + tokio::task::spawn_local(async move { + tracing::debug!("Suspending TUI for claude auth logout"); + crate::app::suspend_terminal(); + + let result = tokio::process::Command::new(&claude_path) + .args(["auth", "logout"]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await; + + crate::app::resume_terminal(); + + match result { + Ok(status) => { + tracing::debug!( + success = status.success(), + code = ?status.code(), + "claude auth logout exited" + ); + if status.success() { + if crate::app::auth::has_credentials() { + let _ = tx.send(ClientEvent::SlashCommandError( + "Logout exited successfully but credentials are still present. \ + Try /logout again or run `claude auth logout` in another terminal." + .to_owned(), + )); + return; + } + let _ = tx.send(ClientEvent::LogoutCompleted); + } else { + let _ = tx.send(ClientEvent::SlashCommandError(format!( + "/logout failed (exit code: {})", + status.code().map_or("unknown".to_owned(), |c| c.to_string()) + ))); + } + } + Err(e) => { + let _ = tx.send(ClientEvent::SlashCommandError(format!( + "Failed to run claude auth logout: {e}" + ))); + } + } + }); + true +} + +/// Resolve the `claude` CLI binary from PATH, or push an error message and return `None`. +fn resolve_claude_cli(app: &mut App, subcommand: &str) -> Option { + if let Ok(path) = which::which("claude") { + tracing::debug!(path = %path.display(), "Resolved claude CLI binary"); + Some(path) + } else { + push_system_message( + app, + format!( + "claude CLI not found in PATH. Install it and retry /{subcommand}, \ + or run `claude auth {subcommand}` manually in another terminal." + ), + ); + None + } +} + +fn handle_mode_submit(app: &mut App, args: &[&str]) -> bool { + let [requested_mode_arg] = args else { + push_system_message(app, "Usage: /mode "); + return true; + }; + let requested_mode = requested_mode_arg.trim(); + if requested_mode.is_empty() { + push_system_message(app, "Usage: /mode "); + return true; + } + + let Some((conn, sid)) = require_active_session( + app, + "Cannot switch mode: not connected yet.", + "Cannot switch mode: no active session.", + ) else { + return true; + }; + + if let Some(ref mode) = app.mode + && !mode.available_modes.iter().any(|m| m.id == requested_mode) + { + push_system_message(app, format!("Unknown mode: {requested_mode}")); + return true; + } + + set_command_pending( + app, + "Switching mode...", + Some(crate::app::PendingCommandAck::CurrentModeUpdate), + ); + + let tx = app.event_tx.clone(); + let requested_mode_owned = requested_mode.to_owned(); + tokio::task::spawn_local(async move { + match conn.set_mode(sid.to_string(), requested_mode_owned) { + Ok(()) => {} + Err(e) => { + let _ = + tx.send(ClientEvent::SlashCommandError(format!("Failed to run /mode: {e}"))); + } + } + }); + true +} + +fn handle_model_submit(app: &mut App, args: &[&str]) -> bool { + let model_name = args.join(" "); + if model_name.trim().is_empty() { + push_system_message(app, "Usage: /model "); + return true; + } + + let Some((conn, sid)) = require_active_session( + app, + "Cannot switch model: not connected yet.", + "Cannot switch model: no active session.", + ) else { + return true; + }; + + if !app.available_models.is_empty() + && !app.available_models.iter().any(|candidate| candidate.id == model_name) + { + push_system_message(app, format!("Unknown model: {model_name}")); + return true; + } + + set_command_pending( + app, + "Switching model...", + Some(crate::app::PendingCommandAck::ConfigOptionUpdate { option_id: "model".to_owned() }), + ); + + let tx = app.event_tx.clone(); + tokio::task::spawn_local(async move { + match conn.set_model(sid.to_string(), model_name) { + Ok(()) => {} + Err(e) => { + let _ = + tx.send(ClientEvent::SlashCommandError(format!("Failed to run /model: {e}"))); + } + } + }); + true +} + +fn handle_new_session_submit(app: &mut App, args: &[&str]) -> bool { + if !args.is_empty() { + push_system_message(app, "Usage: /new-session"); + return true; + } + + push_user_message(app, "/new-session"); + + let Some(conn) = require_connection(app, "Cannot create new session: not connected yet.") + else { + return true; + }; + + set_command_pending(app, "Starting new session...", None); + + if let Err(e) = start_new_session(app, &conn, SessionStartReason::NewSession) { + let _ = app + .event_tx + .send(ClientEvent::SlashCommandError(format!("Failed to run /new-session: {e}"))); + } + true +} + +fn handle_resume_submit(app: &mut App, args: &[&str]) -> bool { + let [session_id_arg] = args else { + push_system_message(app, "Usage: /resume "); + return true; + }; + let session_id = session_id_arg.trim(); + if session_id.is_empty() { + push_system_message(app, "Usage: /resume "); + return true; + } + + push_user_message(app, format!("/resume {session_id}")); + let Some(conn) = require_connection(app, "Cannot resume session: not connected yet.") else { + return true; + }; + + set_command_pending(app, &format!("Resuming session {session_id}..."), None); + app.resuming_session_id = Some(session_id.to_owned()); + let session_id = session_id.to_owned(); + if let Err(e) = resume_session(app, &conn, session_id) { + let _ = app + .event_tx + .send(ClientEvent::SlashCommandError(format!("Failed to run /resume: {e}"))); + } + true +} + +fn handle_unknown_submit(app: &mut App, command_name: &str) -> bool { + if super::candidates::is_supported_command(app, command_name) { + return false; + } + push_system_message(app, format!("{command_name} is not yet supported")); + true +} diff --git a/claude-code-rust/src/app/slash/mod.rs b/claude-code-rust/src/app/slash/mod.rs new file mode 100644 index 0000000..907fcd1 --- /dev/null +++ b/claude-code-rust/src/app/slash/mod.rs @@ -0,0 +1,786 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Slash command types, parsing, and delegation. +//! +//! Submodules: +//! - `candidates`: candidate detection, filtering, and building +//! - `navigation`: autocomplete activation, movement, and confirm +//! - `executors`: slash command execution handlers + +mod candidates; +mod executors; +mod navigation; + +use super::{ + App, AppStatus, ChatMessage, MessageBlock, MessageRole, TextBlock, dialog::DialogState, +}; +use crate::agent::model; +use std::rc::Rc; + +pub const MAX_VISIBLE: usize = 8; +const MAX_CANDIDATES: usize = 50; + +// Re-export public API +pub use executors::try_handle_submit; +pub use navigation::{ + activate, confirm_selection, deactivate, move_down, move_up, sync_with_cursor, update_query, +}; + +#[derive(Debug, Clone)] +pub struct SlashCandidate { + pub insert_value: String, + pub primary: String, + pub secondary: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SlashContext { + CommandName, + Argument { command: String, arg_index: usize, token_range: (usize, usize) }, +} + +#[derive(Debug, Clone)] +pub struct SlashState { + /// Character position where `/` token starts. + pub trigger_row: usize, + pub trigger_col: usize, + /// Current typed query for the active slash context. + pub query: String, + /// Command-name or argument context. + pub context: SlashContext, + /// Filtered list of supported candidates. + pub candidates: Vec, + /// Shared autocomplete dialog navigation state. + pub dialog: DialogState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SlashDetection { + trigger_row: usize, + trigger_col: usize, + query: String, + context: SlashContext, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedSlash<'a> { + name: &'a str, + args: Vec<&'a str>, +} + +fn parse(text: &str) -> Option> { + let trimmed = text.trim(); + if !trimmed.starts_with('/') { + return None; + } + let mut parts = trimmed.split_whitespace(); + let name = parts.next()?; + Some(ParsedSlash { name, args: parts.collect() }) +} + +pub fn is_cancel_command(text: &str) -> bool { + parse(text).is_some_and(|parsed| parsed.name == "/cancel") +} + +fn normalize_slash_name(name: &str) -> String { + if name.starts_with('/') { name.to_owned() } else { format!("/{name}") } +} + +fn push_system_message(app: &mut App, text: impl Into) { + let text = text.into(); + app.push_message_tracked(ChatMessage { + role: MessageRole::System(None), + blocks: vec![MessageBlock::Text(TextBlock::from_complete(&text))], + usage: None, + }); + app.enforce_history_retention_tracked(); + app.viewport.engage_auto_scroll(); +} + +fn push_user_message(app: &mut App, text: impl Into) { + let text = text.into(); + app.push_message_tracked(ChatMessage { + role: MessageRole::User, + blocks: vec![MessageBlock::Text(TextBlock::from_complete(&text))], + usage: None, + }); + app.enforce_history_retention_tracked(); + app.viewport.engage_auto_scroll(); +} + +fn require_connection( + app: &mut App, + not_connected_msg: &'static str, +) -> Option> { + let Some(conn) = app.conn.as_ref() else { + push_system_message(app, not_connected_msg); + return None; + }; + Some(Rc::clone(conn)) +} + +fn require_active_session( + app: &mut App, + not_connected_msg: &'static str, + no_session_msg: &'static str, +) -> Option<(Rc, model::SessionId)> { + let conn = require_connection(app, not_connected_msg)?; + let Some(session_id) = app.session_id.clone() else { + push_system_message(app, no_session_msg); + return None; + }; + Some((conn, session_id)) +} + +/// Block the input field while a slash command is in flight. +fn set_command_pending(app: &mut App, label: &str, ack: Option) { + app.status = AppStatus::CommandPending; + app.pending_command_label = Some(label.to_owned()); + app.pending_command_ack = ack; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + + // Re-import submodule items needed by tests + use super::candidates::{ + argument_candidates, detect_slash_at_cursor, supported_command_candidates, + }; + + #[test] + fn parse_non_slash_returns_none() { + assert!(parse("hello world").is_none()); + } + + #[test] + fn parse_slash_name_and_args() { + let parsed = parse("/mode plan").expect("slash command"); + assert_eq!(parsed.name, "/mode"); + assert_eq!(parsed.args, vec!["plan"]); + } + + #[test] + fn unsupported_command_is_handled_locally() { + let mut app = App::test_default(); + let consumed = try_handle_submit(&mut app, "/definitely-unknown"); + assert!(consumed); + let Some(last) = app.messages.last() else { + panic!("expected system message"); + }; + assert!(matches!(last.role, MessageRole::System(_))); + } + + #[test] + fn advertised_command_is_forwarded() { + let mut app = App::test_default(); + app.available_commands = vec![model::AvailableCommand::new("/help", "Help")]; + let consumed = try_handle_submit(&mut app, "/help"); + assert!(!consumed); + } + + #[test] + fn login_logout_appear_in_candidates_as_builtins() { + let app = App::test_default(); + let names: Vec = + supported_command_candidates(&app).into_iter().map(|c| c.primary).collect(); + assert!(names.iter().any(|n| n == "/config"), "missing /config"); + assert!(names.iter().any(|n| n == "/login"), "missing /login"); + assert!(names.iter().any(|n| n == "/logout"), "missing /logout"); + assert!(names.iter().any(|n| n == "/mcp"), "missing /mcp"); + assert!(names.iter().any(|n| n == "/plugins"), "missing /plugins"); + assert!(names.iter().any(|n| n == "/usage"), "missing /usage"); + } + + #[test] + fn config_without_args_opens_settings_view() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + + let consumed = try_handle_submit(&mut app, "/config"); + + assert!(consumed); + assert_eq!(app.active_view, super::super::ActiveView::Config); + } + + #[test] + fn config_with_extra_args_returns_usage_message() { + let mut app = App::test_default(); + + let consumed = try_handle_submit(&mut app, "/config extra"); + + assert!(consumed); + let Some(last) = app.messages.last() else { + panic!("expected usage message"); + }; + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Usage: /config"); + } + + #[test] + fn plugins_without_args_opens_plugins_tab() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + + let consumed = try_handle_submit(&mut app, "/plugins"); + + assert!(consumed); + assert_eq!(app.active_view, super::super::ActiveView::Config); + assert_eq!(app.config.active_tab, super::super::ConfigTab::Plugins); + } + + #[test] + fn mcp_opens_config_at_mcp_tab() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + + let consumed = try_handle_submit(&mut app, "/mcp"); + + assert!(consumed); + assert_eq!(app.active_view, super::super::ActiveView::Config); + assert_eq!(app.config.active_tab, super::super::ConfigTab::Mcp); + } + + #[test] + fn mcp_with_extra_args_returns_usage() { + let mut app = App::test_default(); + + let consumed = try_handle_submit(&mut app, "/mcp extra"); + + assert!(consumed); + let Some(last) = app.messages.last() else { + panic!("expected usage message"); + }; + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Usage: /mcp"); + } + + #[test] + fn plugins_with_extra_args_still_opens_plugins_tab() { + let mut app = App::test_default(); + let dir = tempfile::tempdir().expect("tempdir"); + app.settings_home_override = Some(dir.path().to_path_buf()); + + let consumed = try_handle_submit(&mut app, "/plugins extra"); + + assert!(consumed); + assert_eq!(app.active_view, super::super::ActiveView::Config); + assert_eq!(app.config.active_tab, super::super::ConfigTab::Plugins); + } + + #[tokio::test(flavor = "current_thread")] + async fn login_is_handled_as_builtin_and_sets_command_pending() { + tokio::task::LocalSet::new() + .run_until(async { + let mut app = App::test_default(); + let consumed = try_handle_submit(&mut app, "/login"); + assert!(consumed, "/login should be handled locally"); + // Status becomes CommandPending (or stays Ready if claude CLI is not in PATH) + assert!( + matches!(app.status, AppStatus::CommandPending | AppStatus::Ready), + "expected CommandPending or Ready, got {:?}", + app.status + ); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn logout_is_handled_as_builtin_and_sets_command_pending() { + tokio::task::LocalSet::new() + .run_until(async { + let mut app = App::test_default(); + let consumed = try_handle_submit(&mut app, "/logout"); + assert!(consumed, "/logout should be handled locally"); + assert!( + matches!(app.status, AppStatus::CommandPending | AppStatus::Ready), + "expected CommandPending or Ready, got {:?}", + app.status + ); + }) + .await; + } + + #[test] + fn login_rejects_extra_args() { + let mut app = App::test_default(); + let consumed = try_handle_submit(&mut app, "/login somearg"); + assert!(consumed); + let last = app.messages.last().expect("expected system message"); + assert!(matches!(last.role, MessageRole::System(_))); + } + + #[test] + fn detect_slash_argument_context_after_first_space() { + let lines = vec!["/mode pla".to_owned()]; + let detection = detect_slash_at_cursor(&lines, 0, "/mode pla".chars().count()) + .expect("slash detection"); + + match detection.context { + SlashContext::Argument { command, arg_index, token_range } => { + assert_eq!(command, "/mode"); + assert_eq!(arg_index, 0); + assert_eq!(token_range, (6, 9)); + } + SlashContext::CommandName => panic!("expected argument context"), + } + assert_eq!(detection.query, "pla"); + } + + #[test] + fn mode_argument_candidates_are_dynamic() { + let mut app = App::test_default(); + app.mode = Some(super::super::ModeState { + current_mode_id: "plan".to_owned(), + current_mode_name: "Plan".to_owned(), + available_modes: vec![ + super::super::ModeInfo { id: "plan".to_owned(), name: "Plan".to_owned() }, + super::super::ModeInfo { id: "code".to_owned(), name: "Code".to_owned() }, + ], + }); + + let candidates = argument_candidates(&app, "/mode", 0); + assert!(candidates.iter().any(|c| c.insert_value == "plan")); + assert!(candidates.iter().any(|c| c.insert_value == "code")); + assert!(candidates.iter().any(|c| c.primary == "Plan")); + assert!(candidates.iter().any(|c| c.secondary.as_deref() == Some("plan"))); + } + + #[test] + fn model_argument_candidates_are_dynamic() { + let mut app = App::test_default(); + app.available_models = vec![ + crate::agent::model::AvailableModel::new("sonnet", "Claude Sonnet") + .description("Balanced coding model"), + crate::agent::model::AvailableModel::new("opus", "Claude Opus"), + ]; + let candidates = argument_candidates(&app, "/model", 0); + assert!(candidates.iter().any(|c| c.insert_value == "sonnet")); + assert!(candidates.iter().any(|c| c.primary == "Claude Sonnet")); + assert!(candidates.iter().any(|c| c.secondary.as_deref() == Some("Balanced coding model"))); + assert!(candidates.iter().any(|c| c.insert_value == "opus")); + } + + #[test] + fn non_variable_command_argument_mode_is_disabled() { + let mut app = App::test_default(); + app.input.set_text("/cancel now"); + let _ = app.input.set_cursor(0, "/cancel now".chars().count()); + sync_with_cursor(&mut app); + assert!(app.slash.is_none()); + } + + #[test] + fn variable_command_argument_mode_deactivates_when_no_match() { + let mut app = App::test_default(); + app.mode = Some(super::super::ModeState { + current_mode_id: "plan".to_owned(), + current_mode_name: "Plan".to_owned(), + available_modes: vec![super::super::ModeInfo { + id: "plan".to_owned(), + name: "Plan".to_owned(), + }], + }); + app.input.set_text("/mode xyz"); + let _ = app.input.set_cursor(0, "/mode xyz".chars().count()); + sync_with_cursor(&mut app); + assert!(app.slash.is_none()); + } + + #[test] + fn confirm_selection_replaces_only_active_argument_token() { + let mut app = App::test_default(); + app.input.set_text("/resume old-id trailing"); + let _ = app.input.set_cursor(0, "/resume old-id".chars().count()); + app.slash = Some(SlashState { + trigger_row: 0, + trigger_col: 8, + query: "old-id".to_owned(), + context: SlashContext::Argument { + command: "/resume".to_owned(), + arg_index: 0, + token_range: (8, 14), + }, + candidates: vec![SlashCandidate { + insert_value: "new-id".to_owned(), + primary: "New".to_owned(), + secondary: None, + }], + dialog: DialogState::default(), + }); + + confirm_selection(&mut app); + + assert_eq!(app.input.text(), "/resume new-id trailing"); + } + + #[tokio::test(flavor = "current_thread")] + async fn login_is_handled_as_builtin_even_when_advertised() { + tokio::task::LocalSet::new() + .run_until(async { + let mut app = App::test_default(); + app.available_commands = vec![model::AvailableCommand::new("/login", "Login")]; + + let consumed = try_handle_submit(&mut app, "/login"); + assert!(consumed, "/login should be handled locally even when SDK advertises it"); + }) + .await; + } + + #[test] + fn new_session_command_is_rendered_as_user_message() { + let mut app = App::test_default(); + + let consumed = try_handle_submit(&mut app, "/new-session"); + assert!(consumed); + assert!(app.messages.len() >= 2); + + let Some(first) = app.messages.first() else { + panic!("expected first message"); + }; + assert!(matches!(first.role, MessageRole::User)); + let Some(MessageBlock::Text(block)) = first.blocks.first() else { + panic!("expected user text block"); + }; + assert_eq!(block.text, "/new-session"); + } + + #[test] + fn resume_with_missing_id_returns_usage() { + let mut app = App::test_default(); + let consumed = try_handle_submit(&mut app, "/resume"); + assert!(consumed); + let Some(last) = app.messages.last() else { + panic!("expected usage message"); + }; + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Usage: /resume "); + } + + #[test] + fn resume_command_is_rendered_as_user_message() { + let mut app = App::test_default(); + + let consumed = try_handle_submit(&mut app, "/resume abc-123"); + assert!(consumed); + assert!(app.messages.len() >= 2); + + let Some(first) = app.messages.first() else { + panic!("expected user message"); + }; + assert!(matches!(first.role, MessageRole::User)); + let Some(MessageBlock::Text(block)) = first.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "/resume abc-123"); + } + + #[tokio::test(flavor = "current_thread")] + async fn resume_sets_command_pending_when_connected() { + tokio::task::LocalSet::new() + .run_until(async { + let mut app = App::test_default(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(std::rc::Rc::new(crate::agent::client::AgentConnection::new(tx))); + + let consumed = try_handle_submit(&mut app, "/resume abc-123"); + assert!(consumed); + assert!(matches!(app.status, AppStatus::CommandPending)); + assert_eq!(app.resuming_session_id.as_deref(), Some("abc-123")); + + tokio::task::yield_now().await; + assert!(rx.try_recv().is_ok()); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn mode_sets_command_pending_and_mode_update_restores_ready() { + tokio::task::LocalSet::new() + .run_until(async { + let mut app = App::test_default(); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(std::rc::Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some("sess-1".into()); + app.mode = Some(super::super::ModeState { + current_mode_id: "code".to_owned(), + current_mode_name: "Code".to_owned(), + available_modes: vec![ + super::super::ModeInfo { id: "plan".to_owned(), name: "Plan".to_owned() }, + super::super::ModeInfo { id: "code".to_owned(), name: "Code".to_owned() }, + ], + }); + + let consumed = try_handle_submit(&mut app, "/mode plan"); + assert!(consumed); + assert!( + matches!(app.status, AppStatus::CommandPending), + "expected CommandPending, got {:?}", + app.status + ); + assert_eq!(app.pending_command_label.as_deref(), Some("Switching mode...")); + + // Simulate mode-update ack arriving from bridge. + super::super::events::handle_client_event( + &mut app, + crate::agent::events::ClientEvent::SessionUpdate( + crate::agent::model::SessionUpdate::CurrentModeUpdate( + crate::agent::model::CurrentModeUpdate::new("plan"), + ), + ), + ); + assert!( + matches!(app.status, AppStatus::Ready), + "expected Ready after CurrentModeUpdate ack, got {:?}", + app.status + ); + assert!(app.pending_command_label.is_none()); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn model_sets_command_pending_and_config_ack_updates_model_and_restores_ready() { + tokio::task::LocalSet::new() + .run_until(async { + let mut app = App::test_default(); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(std::rc::Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some("sess-1".into()); + app.model_name = "old-model".to_owned(); + + let consumed = try_handle_submit(&mut app, "/model sonnet"); + assert!(consumed); + assert!( + matches!(app.status, AppStatus::CommandPending), + "expected CommandPending, got {:?}", + app.status + ); + assert_eq!(app.pending_command_label.as_deref(), Some("Switching model...")); + assert_eq!(app.model_name, "old-model"); + + super::super::events::handle_client_event( + &mut app, + crate::agent::events::ClientEvent::SessionUpdate( + crate::agent::model::SessionUpdate::ConfigOptionUpdate( + crate::agent::model::ConfigOptionUpdate { + option_id: "model".to_owned(), + value: serde_json::Value::String("sonnet".to_owned()), + }, + ), + ), + ); + assert!( + matches!(app.status, AppStatus::Ready), + "expected Ready after model config ack, got {:?}", + app.status + ); + assert_eq!(app.model_name, "sonnet"); + assert!(app.pending_command_label.is_none()); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn new_session_sets_command_pending() { + tokio::task::LocalSet::new() + .run_until(async { + let mut app = App::test_default(); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(std::rc::Rc::new(crate::agent::client::AgentConnection::new(tx))); + + let consumed = try_handle_submit(&mut app, "/new-session"); + assert!(consumed); + assert!( + matches!(app.status, AppStatus::CommandPending), + "expected CommandPending, got {:?}", + app.status + ); + assert_eq!(app.pending_command_label.as_deref(), Some("Starting new session...")); + }) + .await; + } + + #[test] + fn compact_without_connection_is_handled_locally() { + let mut app = App::test_default(); + + let consumed = try_handle_submit(&mut app, "/compact"); + assert!(consumed); + assert!(!app.pending_compact_clear); + let Some(last) = app.messages.last() else { + panic!("expected system message"); + }; + assert!(matches!(last.role, MessageRole::System(_))); + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Cannot compact: not connected yet."); + } + + #[test] + fn compact_with_active_session_sets_compacting_without_success_pending() { + let mut app = App::test_default(); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + app.conn = Some(std::rc::Rc::new(crate::agent::client::AgentConnection::new(tx))); + app.session_id = Some(model::SessionId::new("session-1")); + + let consumed = try_handle_submit(&mut app, "/compact"); + assert!(!consumed); + assert!(!app.pending_compact_clear); + assert!(app.is_compacting); + } + + #[test] + fn compact_with_args_returns_usage_message() { + let mut app = App::test_default(); + app.messages.push(ChatMessage { + role: MessageRole::User, + blocks: vec![MessageBlock::Text(TextBlock::from_complete("keep"))], + usage: None, + }); + + let consumed = try_handle_submit(&mut app, "/compact now"); + assert!(consumed); + assert!(app.messages.len() >= 2); + let Some(last) = app.messages.last() else { + panic!("expected system usage message"); + }; + assert!(matches!(last.role, MessageRole::System(_))); + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Usage: /compact"); + } + + #[test] + fn mode_with_extra_args_returns_usage_message() { + let mut app = App::test_default(); + + let consumed = try_handle_submit(&mut app, "/mode plan extra"); + assert!(consumed); + let Some(last) = app.messages.last() else { + panic!("expected system usage message"); + }; + assert!(matches!(last.role, MessageRole::System(_))); + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Usage: /mode "); + } + + #[test] + fn confirm_selection_with_invalid_trigger_row_is_noop() { + let mut app = App::test_default(); + app.input.set_text("/mode"); + app.slash = Some(SlashState { + trigger_row: 99, + trigger_col: 0, + query: "m".into(), + context: SlashContext::CommandName, + candidates: vec![SlashCandidate { + insert_value: "/mode".into(), + primary: "/mode".into(), + secondary: None, + }], + dialog: DialogState::default(), + }); + + confirm_selection(&mut app); + + assert_eq!(app.input.text(), "/mode"); + } + + #[test] + fn status_opens_config_at_status_tab() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + + let consumed = try_handle_submit(&mut app, "/status"); + + assert!(consumed); + assert_eq!(app.active_view, super::super::ActiveView::Config); + assert_eq!(app.config.active_tab, super::super::ConfigTab::Status); + } + + #[test] + fn usage_opens_config_at_usage_tab() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + + let consumed = try_handle_submit(&mut app, "/usage"); + + assert!(consumed); + assert_eq!(app.active_view, super::super::ActiveView::Config); + assert_eq!(app.config.active_tab, super::super::ConfigTab::Usage); + } + + #[test] + fn status_with_extra_args_returns_usage() { + let mut app = App::test_default(); + + let consumed = try_handle_submit(&mut app, "/status extra"); + + assert!(consumed); + let Some(last) = app.messages.last() else { + panic!("expected usage message"); + }; + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Usage: /status"); + } + + #[test] + fn usage_with_extra_args_returns_usage() { + let mut app = App::test_default(); + + let consumed = try_handle_submit(&mut app, "/usage extra"); + + assert!(consumed); + let Some(last) = app.messages.last() else { + panic!("expected usage message"); + }; + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert_eq!(block.text, "Usage: /usage"); + } + + #[test] + fn status_appears_in_candidates() { + let app = App::test_default(); + let names: Vec = + supported_command_candidates(&app).into_iter().map(|c| c.primary).collect(); + assert!(names.iter().any(|n| n == "/status"), "missing /status"); + } + + #[test] + fn usage_appears_in_candidates() { + let app = App::test_default(); + let names: Vec = + supported_command_candidates(&app).into_iter().map(|c| c.primary).collect(); + assert!(names.iter().any(|n| n == "/usage"), "missing /usage"); + } + + #[test] + fn mcp_appears_in_candidates() { + let app = App::test_default(); + let names: Vec = + supported_command_candidates(&app).into_iter().map(|c| c.primary).collect(); + assert!(names.iter().any(|n| n == "/mcp"), "missing /mcp"); + } +} diff --git a/claude-code-rust/src/app/slash/navigation.rs b/claude-code-rust/src/app/slash/navigation.rs new file mode 100644 index 0000000..8a44622 --- /dev/null +++ b/claude-code-rust/src/app/slash/navigation.rs @@ -0,0 +1,171 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Slash command autocomplete navigation: activate, deactivate, sync, +//! move selection, and confirm. + +use super::candidates::build_slash_state; +use super::{MAX_VISIBLE, SlashContext}; +use crate::app::{App, FocusTarget}; + +pub fn activate(app: &mut App) { + let Some(state) = build_slash_state(app) else { + return; + }; + + app.slash = Some(state); + app.mention = None; + app.subagent = None; + app.claim_focus_target(FocusTarget::Mention); +} + +pub fn update_query(app: &mut App) { + let Some(next_state) = build_slash_state(app) else { + deactivate(app); + return; + }; + + if let Some(ref mut slash) = app.slash { + let keep_selection = slash.context == next_state.context; + let dialog = if keep_selection { slash.dialog } else { super::DialogState::default() }; + slash.trigger_row = next_state.trigger_row; + slash.trigger_col = next_state.trigger_col; + slash.query = next_state.query; + slash.context = next_state.context; + slash.candidates = next_state.candidates; + slash.dialog = dialog; + slash.dialog.clamp(slash.candidates.len(), MAX_VISIBLE); + } else { + app.slash = Some(next_state); + app.claim_focus_target(FocusTarget::Mention); + } +} + +pub fn sync_with_cursor(app: &mut App) { + match (build_slash_state(app), app.slash.is_some()) { + (Some(_), true) => update_query(app), + (Some(_), false) => activate(app), + (None, true) => deactivate(app), + (None, false) => {} + } +} + +pub fn deactivate(app: &mut App) { + app.slash = None; + if app.mention.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } +} + +pub fn move_up(app: &mut App) { + if let Some(ref mut slash) = app.slash { + slash.dialog.move_up(slash.candidates.len(), MAX_VISIBLE); + } +} + +pub fn move_down(app: &mut App) { + if let Some(ref mut slash) = app.slash { + slash.dialog.move_down(slash.candidates.len(), MAX_VISIBLE); + } +} + +/// Confirm selected candidate in input. +pub fn confirm_selection(app: &mut App) { + let Some(slash) = app.slash.take() else { + return; + }; + + let Some(candidate) = slash.candidates.get(slash.dialog.selected) else { + if app.mention.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } + return; + }; + + let mut lines = app.input.lines().to_vec(); + let Some(line) = lines.get(slash.trigger_row) else { + tracing::debug!( + trigger_row = slash.trigger_row, + line_count = app.input.lines().len(), + "Slash confirm aborted: trigger row out of bounds" + ); + if app.mention.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } + return; + }; + + let chars: Vec = line.chars().collect(); + let (replace_start, replace_end) = match slash.context { + SlashContext::CommandName => { + if slash.trigger_col >= chars.len() { + tracing::debug!( + trigger_col = slash.trigger_col, + line_len = chars.len(), + "Slash confirm aborted: trigger column out of bounds" + ); + if app.mention.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } + return; + } + if chars[slash.trigger_col] != '/' { + tracing::debug!( + trigger_col = slash.trigger_col, + found = ?chars[slash.trigger_col], + "Slash confirm aborted: trigger column is not slash" + ); + if app.mention.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } + return; + } + + let token_end = (slash.trigger_col + 1..chars.len()) + .find(|&i| chars[i].is_whitespace()) + .unwrap_or(chars.len()); + (slash.trigger_col, token_end) + } + SlashContext::Argument { token_range, .. } => { + let (start, end) = token_range; + if start > end || end > chars.len() { + tracing::debug!( + start, + end, + line_len = chars.len(), + "Slash confirm aborted: invalid argument token range" + ); + if app.mention.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } + return; + } + (start, end) + } + }; + + let before: String = chars[..replace_start].iter().collect(); + let after: String = chars[replace_end..].iter().collect(); + let replacement = if after.is_empty() { + format!("{} ", candidate.insert_value) + } else { + candidate.insert_value.clone() + }; + let new_line = format!("{before}{replacement}{after}"); + let new_cursor_col = replace_start + replacement.chars().count(); + let new_line_len = new_line.chars().count(); + if new_cursor_col > new_line_len { + tracing::warn!( + cursor_col = new_cursor_col, + line_len = new_line_len, + "Slash confirm produced cursor beyond line length; clamping" + ); + } + lines[slash.trigger_row] = new_line; + app.input.replace_lines_and_cursor(lines, slash.trigger_row, new_cursor_col.min(new_line_len)); + + sync_with_cursor(app); + if app.slash.is_none() && app.mention.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } +} diff --git a/claude-code-rust/src/app/state/block_cache.rs b/claude-code-rust/src/app/state/block_cache.rs new file mode 100644 index 0000000..1ebdf92 --- /dev/null +++ b/claude-code-rust/src/app/state/block_cache.rs @@ -0,0 +1,235 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use std::cell::Cell; +use std::sync::atomic::{AtomicU64, Ordering}; + +static CACHE_ACCESS_TICK: AtomicU64 = AtomicU64::new(1); + +fn next_cache_access_tick() -> u64 { + CACHE_ACCESS_TICK.fetch_add(1, Ordering::Relaxed) +} + +/// Cached rendered lines for a block. Stores a version counter so the cache +/// is only recomputed when the block content actually changes. +/// +/// Fields are private - use `invalidate()` to mark stale, `is_stale()` to check, +/// `get()` to read cached lines, and `store()` to populate. +#[derive(Default)] +pub struct BlockCache { + version: u64, + lines: Option>>, + /// Segmentation metadata for KB-sized cache chunks shared across message/tool caches. + segments: Vec, + /// Approximate UTF-8 byte size of cached rendered lines. + cached_bytes: usize, + /// Wrapped line count of the cached lines at `wrapped_width`. + /// Computed via `Paragraph::line_count(width)` on the same lines stored in `lines`. + wrapped_height: usize, + /// The viewport width used to compute `wrapped_height`. + wrapped_width: u16, + wrapped_height_valid: bool, + last_access_tick: Cell, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CacheLineSegment { + start: usize, + end: usize, + wrapped_height: usize, + wrapped_width: u16, + wrapped_height_valid: bool, +} + +impl CacheLineSegment { + #[must_use] + fn new(start: usize, end: usize) -> Self { + Self { start, end, wrapped_height: 0, wrapped_width: 0, wrapped_height_valid: false } + } +} + +impl BlockCache { + fn touch(&self) { + self.last_access_tick.set(next_cache_access_tick()); + } + + /// Bump the version to invalidate cached lines and height. + pub fn invalidate(&mut self) { + self.version += 1; + self.wrapped_height_valid = false; + } + + /// Get a reference to the cached lines, if fresh. + #[must_use] + pub fn get(&self) -> Option<&Vec>> { + if self.version == 0 { + let lines = self.lines.as_ref(); + if lines.is_some() { + self.touch(); + } + lines + } else { + None + } + } + + /// Store freshly rendered lines, marking the cache as clean. + /// Height is set separately via `set_height()` after measurement. + pub fn store(&mut self, lines: Vec>) { + self.store_with_policy(lines, *super::super::default_cache_split_policy()); + } + + /// Store freshly rendered lines using a shared KB split policy. + pub fn store_with_policy( + &mut self, + lines: Vec>, + policy: super::super::CacheSplitPolicy, + ) { + let segment_limit = policy.hard_limit_bytes.max(1); + let (segments, cached_bytes) = build_line_segments(&lines, segment_limit); + self.lines = Some(lines); + self.segments = segments; + self.cached_bytes = cached_bytes; + self.version = 0; + self.wrapped_height = 0; + self.wrapped_width = 0; + self.wrapped_height_valid = false; + self.touch(); + } + + /// Set the wrapped height for the cached lines at the given width. + /// Called by the viewport/chat layer after `Paragraph::line_count(width)`. + /// Separate from `store()` so height measurement is the viewport's job. + pub fn set_height(&mut self, height: usize, width: u16) { + self.wrapped_height = height; + self.wrapped_width = width; + self.wrapped_height_valid = true; + self.touch(); + } + + /// Store lines and set height in one call. + /// Deprecated: prefer `store()` + `set_height()` to keep concerns separate. + pub fn store_with_height( + &mut self, + lines: Vec>, + height: usize, + width: u16, + ) { + self.store(lines); + self.set_height(height, width); + } + + /// Get the cached wrapped height if cache is valid and was computed at the given width. + #[must_use] + pub fn height_at(&self, width: u16) -> Option { + if self.version == 0 && self.wrapped_height_valid && self.wrapped_width == width { + self.touch(); + Some(self.wrapped_height) + } else { + None + } + } + + /// Recompute wrapped height from cached segments and memoize it at `width`. + /// Returns `None` when the render cache is stale. + pub fn measure_and_set_height(&mut self, width: u16) -> Option { + if self.version != 0 { + return None; + } + if let Some(h) = self.height_at(width) { + return Some(h); + } + + let lines = self.lines.as_ref()?; + + if self.segments.is_empty() { + self.set_height(0, width); + return Some(0); + } + + let mut total_height = 0usize; + for segment in &mut self.segments { + if segment.wrapped_height_valid && segment.wrapped_width == width { + total_height = total_height.saturating_add(segment.wrapped_height); + continue; + } + let segment_lines = lines[segment.start..segment.end].to_vec(); + let h = ratatui::widgets::Paragraph::new(ratatui::text::Text::from(segment_lines)) + .wrap(ratatui::widgets::Wrap { trim: false }) + .line_count(width); + segment.wrapped_height = h; + segment.wrapped_width = width; + segment.wrapped_height_valid = true; + total_height = total_height.saturating_add(h); + } + + self.set_height(total_height, width); + Some(total_height) + } + + #[must_use] + pub fn segment_count(&self) -> usize { + self.segments.len() + } + + #[must_use] + pub fn cached_bytes(&self) -> usize { + self.cached_bytes + } + + #[must_use] + pub fn last_access_tick(&self) -> u64 { + self.last_access_tick.get() + } + + pub fn evict_cached_render(&mut self) -> usize { + let removed = self.cached_bytes; + if removed == 0 { + return 0; + } + self.lines = None; + self.segments.clear(); + self.cached_bytes = 0; + self.wrapped_height = 0; + self.wrapped_width = 0; + self.wrapped_height_valid = false; + self.version = self.version.wrapping_add(1); + removed + } +} + +fn build_line_segments( + lines: &[ratatui::text::Line<'static>], + segment_limit_bytes: usize, +) -> (Vec, usize) { + if lines.is_empty() { + return (Vec::new(), 0); + } + + let limit = segment_limit_bytes.max(1); + let mut segments = Vec::new(); + let mut total_bytes = 0usize; + let mut start = 0usize; + let mut acc = 0usize; + + for (idx, line) in lines.iter().enumerate() { + let line_bytes = line_utf8_bytes(line).max(1); + total_bytes = total_bytes.saturating_add(line_bytes); + + if idx > start && acc.saturating_add(line_bytes) > limit { + segments.push(CacheLineSegment::new(start, idx)); + start = idx; + acc = 0; + } + acc = acc.saturating_add(line_bytes); + } + + segments.push(CacheLineSegment::new(start, lines.len())); + (segments, total_bytes) +} + +fn line_utf8_bytes(line: &ratatui::text::Line<'static>) -> usize { + let span_bytes = + line.spans.iter().fold(0usize, |acc, span| acc.saturating_add(span.content.len())); + span_bytes.saturating_add(1) +} diff --git a/claude-code-rust/src/app/state/cache_metrics.rs b/claude-code-rust/src/app/state/cache_metrics.rs new file mode 100644 index 0000000..fe0803f --- /dev/null +++ b/claude-code-rust/src/app/state/cache_metrics.rs @@ -0,0 +1,530 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Cache observability: accumulation, snapshots, and rate-limited structured logging. +//! +//! `CacheMetrics` lives on `App` and accumulates cross-cutting counters (enforcement +//! counts, watermarks, rate-limit cooldown state). `CacheMetricsSnapshot` is a +//! computed on-demand view that pulls from `RenderCacheBudget`, `HistoryRetentionStats`, +//! `CacheMetrics`, and `ChatViewport`. +//! +//! All structured tracing uses `target: "cache"` so it can be enabled via +//! `--log-filter "cache=debug"` without affecting other log targets. + +use super::types::{ + CacheBudgetEnforceStats, HistoryRetentionPolicy, HistoryRetentionStats, RenderCacheBudget, +}; +use super::viewport::ChatViewport; + +// --------------------------------------------------------------------------- +// Rate-limit constants +// --------------------------------------------------------------------------- + +/// Emit render-cache debug log every N enforcement calls (~1/sec at 60 FPS). +const RENDER_LOG_INTERVAL: u64 = 60; + +/// Emit history-retention debug log every N enforcement calls. +const HISTORY_LOG_INTERVAL: u64 = 10; + +/// Suppress repeated warn-level emissions for this many enforcement calls (~5 sec). +const WARN_COOLDOWN_CALLS: u64 = 300; + +/// Utilization percentage that triggers a warn-level log. +const HIGH_UTILIZATION_THRESHOLD: f32 = 90.0; + +/// Number of blocks evicted in a single pass that triggers a warn-level log. +const EVICTION_SPIKE_THRESHOLD: usize = 10; + +// --------------------------------------------------------------------------- +// Persistent accumulator (lives on App) +// --------------------------------------------------------------------------- + +/// Cross-cutting cache metrics accumulated over the session lifetime. +/// +/// Updated by `record_render_enforcement` and `record_history_enforcement` +/// after each budget enforcement pass. Rate-limit cooldown state is internal +/// and not meaningful to external consumers. +#[derive(Debug, Clone, Copy)] +pub struct CacheMetrics { + // -- Render cache -- + pub render_enforcement_count: u64, + pub render_peak_bytes: usize, + + // -- History retention -- + pub history_enforcement_count: u64, + pub history_peak_bytes: usize, + + // -- Viewport -- + pub resize_count: u64, + + // -- Rate-limit cooldown (private) -- + render_log_countdown: u64, + history_log_countdown: u64, + warn_cooldown_remaining: u64, +} + +impl Default for CacheMetrics { + fn default() -> Self { + Self { + render_enforcement_count: 0, + render_peak_bytes: 0, + history_enforcement_count: 0, + history_peak_bytes: 0, + resize_count: 0, + // Fire on the very first call (countdown starts at 1 so it + // decrements to 0 and triggers immediately). + render_log_countdown: 1, + history_log_countdown: 1, + warn_cooldown_remaining: 0, + } + } +} + +impl CacheMetrics { + /// Record one render-cache enforcement pass. + /// + /// Returns `true` when a debug-level log should be emitted (every + /// `RENDER_LOG_INTERVAL` calls). + pub fn record_render_enforcement( + &mut self, + stats: &CacheBudgetEnforceStats, + _budget: &RenderCacheBudget, + ) -> bool { + self.render_enforcement_count += 1; + if stats.total_before_bytes > self.render_peak_bytes { + self.render_peak_bytes = stats.total_before_bytes; + } + + self.render_log_countdown -= 1; + if self.render_log_countdown == 0 { + self.render_log_countdown = RENDER_LOG_INTERVAL; + true + } else { + false + } + } + + /// Record one history-retention enforcement pass. + /// + /// Returns `true` when a debug-level log should be emitted (every + /// `HISTORY_LOG_INTERVAL` calls). + pub fn record_history_enforcement( + &mut self, + stats: &HistoryRetentionStats, + _policy: HistoryRetentionPolicy, + ) -> bool { + self.history_enforcement_count += 1; + if stats.total_before_bytes > self.history_peak_bytes { + self.history_peak_bytes = stats.total_before_bytes; + } + + self.history_log_countdown -= 1; + if self.history_log_countdown == 0 { + self.history_log_countdown = HISTORY_LOG_INTERVAL; + true + } else { + false + } + } + + /// Record a viewport resize event. + pub fn record_resize(&mut self) { + self.resize_count += 1; + } + + /// Check whether a warn-level log should fire based on current utilization + /// and eviction counts. Returns `Some(kind)` and resets the cooldown, or + /// `None` if suppressed. + pub fn check_warn_condition( + &mut self, + render_util_pct: f32, + history_util_pct: f32, + evicted_blocks: usize, + ) -> Option { + if self.warn_cooldown_remaining > 0 { + self.warn_cooldown_remaining -= 1; + return None; + } + + let kind = if render_util_pct >= HIGH_UTILIZATION_THRESHOLD { + Some(CacheWarnKind::HighRenderUtilization(render_util_pct)) + } else if history_util_pct >= HIGH_UTILIZATION_THRESHOLD { + Some(CacheWarnKind::HighHistoryUtilization(history_util_pct)) + } else if evicted_blocks >= EVICTION_SPIKE_THRESHOLD { + Some(CacheWarnKind::EvictionSpike(evicted_blocks)) + } else { + None + }; + + if kind.is_some() { + self.warn_cooldown_remaining = WARN_COOLDOWN_CALLS; + } + kind + } +} + +// --------------------------------------------------------------------------- +// Warn kind +// --------------------------------------------------------------------------- + +/// Classification of cache warning conditions for structured logging. +#[derive(Debug, Clone, Copy)] +pub enum CacheWarnKind { + HighRenderUtilization(f32), + HighHistoryUtilization(f32), + EvictionSpike(usize), +} + +// --------------------------------------------------------------------------- +// On-demand snapshot (not stored) +// --------------------------------------------------------------------------- + +/// Point-in-time view of all cache subsystems, computed on demand. +#[derive(Debug, Clone, Copy)] +pub struct CacheMetricsSnapshot { + // Render cache + pub render_bytes: usize, + pub render_max_bytes: usize, + pub render_utilization_pct: f32, + pub render_entry_count: usize, + pub render_evictions_this_frame: usize, + pub render_total_evictions: usize, + pub render_enforcement_count: u64, + pub render_peak_bytes: usize, + /// Bytes in protected (non-evictable) blocks excluded from the budget comparison. + pub render_protected_bytes: usize, + + // History retention + pub history_bytes: usize, + pub history_max_bytes: usize, + pub history_utilization_pct: f32, + pub history_dropped_messages_this_pass: usize, + pub history_total_dropped_messages: usize, + pub history_total_dropped_bytes: usize, + pub history_enforcement_count: u64, + pub history_peak_bytes: usize, + + // Viewport dirtiness + pub viewport_prefix_dirty_from: Option, + pub viewport_stale_messages: usize, + pub viewport_remeasure_active: bool, + pub viewport_width_valid: bool, + pub viewport_prefix_sums_valid: bool, + pub resize_count: u64, +} + +/// Build a snapshot from all cache subsystem state. +/// +/// Only called on log cadence (not every frame), so the cost of collecting +/// fields is negligible. +#[must_use] +#[allow(clippy::too_many_arguments, clippy::cast_precision_loss)] +pub fn build_snapshot( + budget: &RenderCacheBudget, + retention_stats: &HistoryRetentionStats, + retention_policy: HistoryRetentionPolicy, + metrics: &CacheMetrics, + viewport: &ChatViewport, + render_entry_count: usize, + evictions_this_frame: usize, + dropped_this_pass: usize, + protected_bytes: usize, +) -> CacheMetricsSnapshot { + let render_util = if budget.max_bytes > 0 { + (budget.last_total_bytes as f32 / budget.max_bytes as f32) * 100.0 + } else { + 0.0 + }; + let history_util = if retention_policy.max_bytes > 0 { + (retention_stats.total_after_bytes as f32 / retention_policy.max_bytes as f32) * 100.0 + } else { + 0.0 + }; + + CacheMetricsSnapshot { + render_bytes: budget.last_total_bytes, + render_max_bytes: budget.max_bytes, + render_utilization_pct: render_util, + render_entry_count, + render_evictions_this_frame: evictions_this_frame, + render_total_evictions: budget.total_evictions, + render_enforcement_count: metrics.render_enforcement_count, + render_peak_bytes: metrics.render_peak_bytes, + render_protected_bytes: protected_bytes, + + history_bytes: retention_stats.total_after_bytes, + history_max_bytes: retention_policy.max_bytes, + history_utilization_pct: history_util, + history_dropped_messages_this_pass: dropped_this_pass, + history_total_dropped_messages: retention_stats.total_dropped_messages, + history_total_dropped_bytes: retention_stats.total_dropped_bytes, + history_enforcement_count: metrics.history_enforcement_count, + history_peak_bytes: metrics.history_peak_bytes, + + viewport_prefix_dirty_from: viewport.prefix_dirty_from(), + viewport_stale_messages: viewport.stale_message_count(), + viewport_remeasure_active: viewport.remeasure_active(), + viewport_width_valid: viewport.message_heights_width == viewport.width + && viewport.width > 0, + viewport_prefix_sums_valid: viewport.prefix_sums_width == viewport.width + && viewport.width > 0, + resize_count: metrics.resize_count, + } +} + +// --------------------------------------------------------------------------- +// Structured tracing emitters +// --------------------------------------------------------------------------- + +/// Emit a debug-level structured log summarizing render cache state. +pub fn emit_render_metrics(snap: &CacheMetricsSnapshot) { + tracing::debug!( + target: "cache", + render_bytes = snap.render_bytes, + render_max = snap.render_max_bytes, + render_util_pct = format_args!("{:.1}", snap.render_utilization_pct), + render_entries = snap.render_entry_count, + render_protected = snap.render_protected_bytes, + render_evictions_frame = snap.render_evictions_this_frame, + render_evictions_total = snap.render_total_evictions, + render_peak = snap.render_peak_bytes, + render_enforcements = snap.render_enforcement_count, + viewport_prefix_dirty_from = ?snap.viewport_prefix_dirty_from, + viewport_stale_messages = snap.viewport_stale_messages, + viewport_remeasure_active = snap.viewport_remeasure_active, + viewport_width_valid = snap.viewport_width_valid, + viewport_prefix_sums_valid = snap.viewport_prefix_sums_valid, + resize_count = snap.resize_count, + "render cache metrics" + ); +} + +/// Emit a debug-level structured log summarizing history retention state. +pub fn emit_history_metrics(snap: &CacheMetricsSnapshot) { + tracing::debug!( + target: "cache", + history_bytes = snap.history_bytes, + history_max = snap.history_max_bytes, + history_util_pct = format_args!("{:.1}", snap.history_utilization_pct), + history_dropped_pass = snap.history_dropped_messages_this_pass, + history_dropped_total = snap.history_total_dropped_messages, + history_dropped_bytes_total = snap.history_total_dropped_bytes, + history_peak = snap.history_peak_bytes, + history_enforcements = snap.history_enforcement_count, + "history retention metrics" + ); +} + +/// Emit a warn-level structured log for a cache warning condition. +pub fn emit_cache_warning(kind: &CacheWarnKind) { + match kind { + CacheWarnKind::HighRenderUtilization(pct) => { + tracing::warn!( + target: "cache", + util_pct = format_args!("{:.1}", pct), + "render cache utilization high" + ); + } + CacheWarnKind::HighHistoryUtilization(pct) => { + tracing::warn!( + target: "cache", + util_pct = format_args!("{:.1}", pct), + "history retention utilization high" + ); + } + CacheWarnKind::EvictionSpike(blocks) => { + tracing::warn!( + target: "cache", + evicted_blocks = blocks, + "render cache eviction spike" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn make_render_stats(before_bytes: usize, evicted_blocks: usize) -> CacheBudgetEnforceStats { + CacheBudgetEnforceStats { + total_before_bytes: before_bytes, + total_after_bytes: before_bytes, + evicted_bytes: 0, + evicted_blocks, + protected_bytes: 0, + } + } + + fn make_history_stats(before_bytes: usize, dropped: usize) -> HistoryRetentionStats { + HistoryRetentionStats { + total_before_bytes: before_bytes, + total_after_bytes: before_bytes, + dropped_messages: dropped, + dropped_bytes: 0, + total_dropped_messages: dropped, + total_dropped_bytes: 0, + } + } + + #[test] + fn render_log_fires_on_first_call_and_at_interval() { + let mut m = CacheMetrics::default(); + let stats = make_render_stats(1000, 0); + let budget = RenderCacheBudget::default(); + + // First call should fire (countdown starts at 1). + assert!(m.record_render_enforcement(&stats, &budget)); + + // Next RENDER_LOG_INTERVAL - 1 calls should not fire. + for _ in 1..RENDER_LOG_INTERVAL { + assert!(!m.record_render_enforcement(&stats, &budget)); + } + + // The interval-th call fires again. + assert!(m.record_render_enforcement(&stats, &budget)); + assert_eq!(m.render_enforcement_count, RENDER_LOG_INTERVAL + 1); + } + + #[test] + fn history_log_fires_on_first_call_and_at_interval() { + let mut m = CacheMetrics::default(); + let stats = make_history_stats(2000, 0); + let policy = HistoryRetentionPolicy::default(); + + assert!(m.record_history_enforcement(&stats, policy)); + + for _ in 1..HISTORY_LOG_INTERVAL { + assert!(!m.record_history_enforcement(&stats, policy)); + } + + assert!(m.record_history_enforcement(&stats, policy)); + assert_eq!(m.history_enforcement_count, HISTORY_LOG_INTERVAL + 1); + } + + #[test] + fn peak_bytes_tracks_maximum() { + let mut m = CacheMetrics::default(); + let budget = RenderCacheBudget::default(); + + m.record_render_enforcement(&make_render_stats(5000, 0), &budget); + assert_eq!(m.render_peak_bytes, 5000); + + m.record_render_enforcement(&make_render_stats(3000, 0), &budget); + assert_eq!(m.render_peak_bytes, 5000); // unchanged + + m.record_render_enforcement(&make_render_stats(8000, 0), &budget); + assert_eq!(m.render_peak_bytes, 8000); // updated + } + + #[test] + fn history_peak_bytes_tracks_maximum() { + let mut m = CacheMetrics::default(); + let policy = HistoryRetentionPolicy::default(); + + m.record_history_enforcement(&make_history_stats(10_000, 0), policy); + assert_eq!(m.history_peak_bytes, 10_000); + + m.record_history_enforcement(&make_history_stats(5_000, 0), policy); + assert_eq!(m.history_peak_bytes, 10_000); + } + + #[test] + fn warn_fires_then_cooldown_suppresses() { + let mut m = CacheMetrics::default(); + + // High render utilization should fire. + let kind = m.check_warn_condition(95.0, 50.0, 0); + assert!(matches!(kind, Some(CacheWarnKind::HighRenderUtilization(_)))); + + // Immediately again: suppressed by cooldown. + assert!(m.check_warn_condition(95.0, 50.0, 0).is_none()); + + // Drain cooldown. + for _ in 0..WARN_COOLDOWN_CALLS - 1 { + assert!(m.check_warn_condition(95.0, 50.0, 0).is_none()); + } + + // After cooldown, should fire again. + assert!(m.check_warn_condition(95.0, 50.0, 0).is_some()); + } + + #[test] + fn warn_does_not_fire_below_threshold() { + let mut m = CacheMetrics::default(); + assert!(m.check_warn_condition(80.0, 80.0, 5).is_none()); + } + + #[test] + fn eviction_spike_triggers_warn() { + let mut m = CacheMetrics::default(); + let kind = m.check_warn_condition(50.0, 50.0, EVICTION_SPIKE_THRESHOLD); + assert!(matches!(kind, Some(CacheWarnKind::EvictionSpike(_)))); + } + + #[test] + fn resize_count_increments() { + let mut m = CacheMetrics::default(); + assert_eq!(m.resize_count, 0); + m.record_resize(); + m.record_resize(); + m.record_resize(); + assert_eq!(m.resize_count, 3); + } + + #[test] + fn snapshot_utilization_computed_correctly() { + let budget = RenderCacheBudget { + max_bytes: 1000, + last_total_bytes: 500, + last_evicted_bytes: 0, + total_evictions: 0, + }; + let retention_stats = + HistoryRetentionStats { total_after_bytes: 750, ..Default::default() }; + let policy = HistoryRetentionPolicy { max_bytes: 1000 }; + let metrics = CacheMetrics::default(); + let viewport = ChatViewport::new(); + + let snap = + build_snapshot(&budget, &retention_stats, policy, &metrics, &viewport, 10, 2, 1, 0); + + assert!((snap.render_utilization_pct - 50.0).abs() < 0.01); + assert!((snap.history_utilization_pct - 75.0).abs() < 0.01); + assert_eq!(snap.render_entry_count, 10); + assert_eq!(snap.render_evictions_this_frame, 2); + assert_eq!(snap.history_dropped_messages_this_pass, 1); + assert_eq!(snap.render_protected_bytes, 0); + } + + #[test] + fn snapshot_zero_budget_no_panic() { + let budget = RenderCacheBudget { + max_bytes: 0, + last_total_bytes: 0, + last_evicted_bytes: 0, + total_evictions: 0, + }; + let policy = HistoryRetentionPolicy { max_bytes: 0 }; + let metrics = CacheMetrics::default(); + let viewport = ChatViewport::new(); + + let snap = build_snapshot( + &budget, + &HistoryRetentionStats::default(), + policy, + &metrics, + &viewport, + 0, + 0, + 0, + 0, + ); + assert!(snap.render_utilization_pct.abs() < f32::EPSILON); + assert!(snap.history_utilization_pct.abs() < f32::EPSILON); + } +} diff --git a/claude-code-rust/src/app/state/history_retention.rs b/claude-code-rust/src/app/state/history_retention.rs new file mode 100644 index 0000000..176505f --- /dev/null +++ b/claude-code-rust/src/app/state/history_retention.rs @@ -0,0 +1,637 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::agent::model; +use std::cmp::Ordering; +use std::collections::HashSet; +use std::mem::size_of; + +use super::LayoutInvalidation as InvalidationLevel; +use super::LayoutRemeasureReason; +use super::messages::{ + ChatMessage, IncrementalMarkdown, MessageBlock, MessageRole, TextBlock, WelcomeBlock, +}; +use super::tool_call_info::{InlinePermission, InlineQuestion, ToolCallInfo}; +use super::types::{HistoryRetentionStats, MessageUsage, RecentSessionInfo}; + +const HISTORY_HIDDEN_MARKER_PREFIX: &str = "Older messages hidden to keep memory bounded"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct HistoryDropCandidate { + pub(super) msg_idx: usize, + pub(super) bytes: usize, +} + +impl super::App { + fn remap_anchor_for_insert( + anchor: Option<(usize, usize)>, + insert_idx: usize, + ) -> Option<(usize, usize)> { + anchor.map(|(anchor_idx, anchor_offset)| { + let next_idx = + if anchor_idx >= insert_idx { anchor_idx.saturating_add(1) } else { anchor_idx }; + (next_idx, anchor_offset) + }) + } + + fn remap_anchor_for_remove( + anchor: Option<(usize, usize)>, + removed_idx: usize, + retained_len: usize, + ) -> Option<(usize, usize)> { + let (anchor_idx, anchor_offset) = anchor?; + if retained_len == 0 { + return None; + } + + let next_idx = match anchor_idx.cmp(&removed_idx) { + Ordering::Less => anchor_idx, + Ordering::Greater => anchor_idx.saturating_sub(1), + Ordering::Equal => removed_idx.min(retained_len.saturating_sub(1)), + }; + Some((next_idx.min(retained_len.saturating_sub(1)), anchor_offset)) + } + + fn invalidate_tail_transition( + &mut self, + previous_tail_after_mutation: Option, + new_tail: Option, + ) { + if let Some(idx) = previous_tail_after_mutation { + self.viewport.invalidate_message(idx); + } + if let Some(idx) = new_tail + && Some(idx) != previous_tail_after_mutation + { + self.viewport.invalidate_message(idx); + } + } + + fn sync_after_message_topology_change(&mut self, start_idx: usize) { + self.rebuild_tool_indices_and_terminal_refs(); + if self.messages.is_empty() { + self.viewport.sync_message_count(0); + return; + } + self.invalidate_layout(InvalidationLevel::MessagesFrom(start_idx)); + } + + #[must_use] + pub(super) fn is_history_hidden_marker_message(msg: &ChatMessage) -> bool { + if !matches!(msg.role, MessageRole::System(_)) { + return false; + } + let Some(MessageBlock::Text(block)) = msg.blocks.first() else { + return false; + }; + block.text.starts_with(HISTORY_HIDDEN_MARKER_PREFIX) + } + + #[must_use] + pub(super) fn is_history_protected_message(msg: &ChatMessage) -> bool { + if matches!(msg.role, MessageRole::Welcome) { + return true; + } + msg.blocks.iter().any(|block| { + if let MessageBlock::ToolCall(tc) = block { + tc.pending_permission.is_some() + || tc.pending_question.is_some() + || matches!( + tc.status, + model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress + ) + } else { + false + } + }) + } + + #[must_use] + fn measure_tool_content_bytes(content: &model::ToolCallContent) -> usize { + match content { + model::ToolCallContent::Content(inner) => match &inner.content { + model::ContentBlock::Text(text) => text.text.capacity(), + model::ContentBlock::Image(image) => { + image.data.capacity().saturating_add(image.mime_type.capacity()) + } + }, + model::ToolCallContent::Diff(diff) => diff + .path + .capacity() + .saturating_add(diff.old_text.as_ref().map_or(0, String::capacity)) + .saturating_add(diff.new_text.capacity()), + model::ToolCallContent::McpResource(resource) => resource + .uri + .capacity() + .saturating_add(resource.mime_type.as_ref().map_or(0, String::capacity)) + .saturating_add(resource.text.as_ref().map_or(0, String::capacity)) + .saturating_add( + resource.blob_saved_to.as_ref().map_or(0, std::path::PathBuf::capacity), + ), + model::ToolCallContent::Terminal(term) => term.terminal_id.capacity(), + } + } + + #[must_use] + fn measure_tool_call_bytes(tc: &ToolCallInfo) -> usize { + let mut total = size_of::() + .saturating_add(tc.id.capacity()) + .saturating_add(tc.title.capacity()) + .saturating_add(tc.sdk_tool_name.capacity()) + .saturating_add(tc.terminal_id.as_ref().map_or(0, String::capacity)) + .saturating_add(tc.terminal_command.as_ref().map_or(0, String::capacity)) + .saturating_add(tc.terminal_output.as_ref().map_or(0, String::capacity)) + .saturating_add( + tc.content.capacity().saturating_mul(size_of::()), + ); + + total = total.saturating_add(tc.raw_input_bytes); + for content in &tc.content { + total = total.saturating_add(Self::measure_tool_content_bytes(content)); + } + if let Some(permission) = &tc.pending_permission { + total = total.saturating_add(size_of::()).saturating_add( + permission.options.capacity().saturating_mul(size_of::()), + ); + for option in &permission.options { + total = total + .saturating_add(option.option_id.capacity()) + .saturating_add(option.name.capacity()) + .saturating_add(option.description.as_ref().map_or(0, String::capacity)); + } + } + if let Some(question) = &tc.pending_question { + total = total + .saturating_add(size_of::()) + .saturating_add(question.prompt.question.capacity()) + .saturating_add(question.prompt.header.capacity()) + .saturating_add( + question + .prompt + .options + .capacity() + .saturating_mul(size_of::()), + ) + .saturating_add(question.notes.capacity()); + for option in &question.prompt.options { + total = total + .saturating_add(option.option_id.capacity()) + .saturating_add(option.label.capacity()) + .saturating_add(option.description.as_ref().map_or(0, String::capacity)) + .saturating_add(option.preview.as_ref().map_or(0, String::capacity)); + } + } + + total + } + + /// Measure the approximate in-memory byte footprint of a single message. + /// + /// Uses `String::capacity()` and `std::mem::size_of` for actual heap + /// allocation sizes rather than content-length heuristics. + #[must_use] + pub fn measure_message_bytes(msg: &ChatMessage) -> usize { + let mut total = size_of::() + .saturating_add(msg.blocks.capacity().saturating_mul(size_of::())); + if msg.usage.is_some() { + total = total.saturating_add(size_of::()); + } + + for block in &msg.blocks { + match block { + MessageBlock::Text(block) => { + total = total + .saturating_add(block.text.capacity()) + .saturating_add(block.markdown.text_capacity()); + } + MessageBlock::ToolCall(tc) => { + total = total.saturating_add(Self::measure_tool_call_bytes(tc)); + } + MessageBlock::Welcome(welcome) => { + total = total + .saturating_add(size_of::()) + .saturating_add(welcome.model_name.capacity()) + .saturating_add(welcome.cwd.capacity()) + .saturating_add( + welcome + .recent_sessions + .capacity() + .saturating_mul(size_of::()), + ); + for session in &welcome.recent_sessions { + total = total + .saturating_add(session.session_id.capacity()) + .saturating_add(session.summary.capacity()) + .saturating_add(session.cwd.as_ref().map_or(0, String::capacity)) + .saturating_add(session.git_branch.as_ref().map_or(0, String::capacity)) + .saturating_add( + session.custom_title.as_ref().map_or(0, String::capacity), + ) + .saturating_add( + session.first_prompt.as_ref().map_or(0, String::capacity), + ); + } + } + } + } + total + } + + /// Measure the total in-memory byte footprint of all retained messages. + #[must_use] + pub fn measure_history_bytes(&self) -> usize { + self.messages.iter().map(Self::measure_message_bytes).sum() + } + + pub(crate) fn rebuild_history_retention_accounting(&mut self) { + self.message_retained_bytes.clear(); + self.message_retained_bytes.reserve(self.messages.len()); + self.retained_history_bytes = 0; + + for msg in &self.messages { + let bytes = Self::measure_message_bytes(msg); + self.message_retained_bytes.push(bytes); + self.retained_history_bytes = self.retained_history_bytes.saturating_add(bytes); + } + } + + pub(crate) fn ensure_history_retention_accounting(&mut self) { + if self.message_retained_bytes.len() != self.messages.len() { + self.rebuild_history_retention_accounting(); + } + } + + pub(crate) fn push_message_tracked(&mut self, msg: ChatMessage) { + let previous_tail = self.messages.len().checked_sub(1); + let bytes = Self::measure_message_bytes(&msg); + self.messages.push(msg); + self.message_retained_bytes.push(bytes); + self.retained_history_bytes = self.retained_history_bytes.saturating_add(bytes); + self.rebuild_render_cache_accounting(); + self.invalidate_tail_transition(previous_tail, self.messages.len().checked_sub(1)); + } + + pub(crate) fn insert_message_tracked(&mut self, idx: usize, msg: ChatMessage) { + self.ensure_history_retention_accounting(); + let insert_idx = idx.min(self.messages.len()); + let appended_at_tail = insert_idx == self.messages.len(); + if !appended_at_tail { + self.shift_active_turn_assistant_for_insert(insert_idx); + } + let bytes = Self::measure_message_bytes(&msg); + self.messages.insert(insert_idx, msg); + self.message_retained_bytes.insert(insert_idx, bytes); + self.retained_history_bytes = self.retained_history_bytes.saturating_add(bytes); + self.rebuild_render_cache_accounting(); + if appended_at_tail { + let new_tail = self.messages.len().checked_sub(1); + self.invalidate_tail_transition( + new_tail.and_then(|tail| tail.checked_sub(1)), + new_tail, + ); + } else { + self.sync_after_message_topology_change(insert_idx); + } + } + + pub(crate) fn remove_message_tracked(&mut self, idx: usize) -> Option { + self.ensure_history_retention_accounting(); + let old_len = self.messages.len(); + if idx >= old_len { + return None; + } + let removed_tail = idx + 1 == old_len; + self.shift_active_turn_assistant_for_remove(idx); + let removed = self.messages.remove(idx); + let removed_bytes = self.message_retained_bytes.remove(idx); + self.retained_history_bytes = self.retained_history_bytes.saturating_sub(removed_bytes); + self.rebuild_render_cache_accounting(); + self.rebuild_tool_indices_and_terminal_refs(); + if removed_tail { + self.invalidate_tail_transition(None, self.messages.len().checked_sub(1)); + } else if !self.messages.is_empty() { + self.invalidate_layout(InvalidationLevel::MessagesFrom(idx)); + } else { + self.viewport.sync_message_count(0); + } + Some(removed) + } + + pub(crate) fn clear_messages_tracked(&mut self) { + self.messages.clear(); + self.message_retained_bytes.clear(); + self.retained_history_bytes = 0; + self.clear_active_turn_assistant(); + self.rebuild_render_cache_accounting(); + self.rebuild_tool_indices_and_terminal_refs(); + self.viewport.sync_message_count(0); + } + + pub(crate) fn recompute_message_retained_bytes(&mut self, idx: usize) { + self.ensure_history_retention_accounting(); + let Some(msg) = self.messages.get(idx) else { + return; + }; + let new_bytes = Self::measure_message_bytes(msg); + let Some(old_bytes) = self.message_retained_bytes.get_mut(idx) else { + self.rebuild_history_retention_accounting(); + return; + }; + self.retained_history_bytes = + self.retained_history_bytes.saturating_sub(*old_bytes).saturating_add(new_bytes); + *old_bytes = new_bytes; + } + + pub(super) fn rebuild_tool_indices_and_terminal_refs(&mut self) { + self.tool_call_index.clear(); + self.clear_terminal_tool_call_tracking(); + self.active_task_ids.clear(); + self.active_subagent_tool_ids.clear(); + + let mut pending_interaction_ids = Vec::new(); + let mut terminal_tool_call_membership = HashSet::new(); + let mut terminal_tool_calls = Vec::new(); + let previous_idle_since = self.subagent_idle_since; + for (msg_idx, msg) in self.messages.iter_mut().enumerate() { + for (block_idx, block) in msg.blocks.iter_mut().enumerate() { + if let MessageBlock::ToolCall(tc) = block { + let tc = tc.as_mut(); + self.tool_call_index.insert(tc.id.clone(), (msg_idx, block_idx)); + if let Some(terminal_id) = Self::tracked_terminal_id_for_tool(tc) { + let entry = + super::TerminalToolCallRef::new(terminal_id, msg_idx, block_idx); + if terminal_tool_call_membership.insert(entry.clone()) { + terminal_tool_calls.push(entry); + } + } + if let Some(permission) = tc.pending_permission.as_mut() { + permission.focused = false; + pending_interaction_ids.push(tc.id.clone()); + } + if let Some(question) = tc.pending_question.as_mut() { + question.focused = false; + pending_interaction_ids.push(tc.id.clone()); + } + } + } + } + self.terminal_tool_calls = terminal_tool_calls; + self.terminal_tool_call_membership = terminal_tool_call_membership; + self.tool_call_scopes.retain(|id, _| self.tool_call_index.contains_key(id)); + for msg in &self.messages { + for block in &msg.blocks { + let MessageBlock::ToolCall(tc) = block else { + continue; + }; + if !matches!( + tc.status, + model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress + ) { + continue; + } + match self.tool_call_scopes.get(&tc.id).copied() { + Some(super::ToolCallScope::Task) => { + self.active_task_ids.insert(tc.id.clone()); + } + Some(super::ToolCallScope::Subagent) => { + self.active_subagent_tool_ids.insert(tc.id.clone()); + } + Some(super::ToolCallScope::MainAgent) | None => {} + } + } + } + self.subagent_idle_since = + if self.active_task_ids.is_empty() || !self.active_subagent_tool_ids.is_empty() { + None + } else { + previous_idle_since + }; + + let interaction_set: HashSet<&str> = + pending_interaction_ids.iter().map(String::as_str).collect(); + self.pending_interaction_ids.retain(|id| interaction_set.contains(id.as_str())); + for id in pending_interaction_ids { + if !self.pending_interaction_ids.iter().any(|existing| existing == &id) { + self.pending_interaction_ids.push(id); + } + } + + if let Some(first_id) = self.pending_interaction_ids.first().cloned() { + self.claim_focus_target(super::super::focus::FocusTarget::Permission); + if let Some((msg_idx, block_idx)) = self.lookup_tool_call(&first_id) + && let Some(MessageBlock::ToolCall(tc)) = + self.messages.get_mut(msg_idx).and_then(|m| m.blocks.get_mut(block_idx)) + { + if let Some(permission) = tc.pending_permission.as_mut() { + permission.focused = true; + } + if let Some(question) = tc.pending_question.as_mut() { + question.focused = true; + } + } + } else { + self.release_focus_target(super::super::focus::FocusTarget::Permission); + } + self.normalize_focus_stack(); + } + + #[must_use] + fn format_mib_tenths(bytes: usize) -> String { + let tenths = + (u128::try_from(bytes).unwrap_or(u128::MAX).saturating_mul(10) + 524_288) / 1_048_576; + format!("{}.{}", tenths / 10, tenths % 10) + } + + #[must_use] + fn history_hidden_marker_text( + total_dropped_messages: usize, + total_dropped_bytes: usize, + ) -> String { + format!( + "{HISTORY_HIDDEN_MARKER_PREFIX} (dropped {total_dropped_messages} messages, {} MiB).", + Self::format_mib_tenths(total_dropped_bytes) + ) + } + + fn upsert_history_hidden_marker( + &mut self, + preserved_anchor: Option<(usize, usize)>, + ) -> Option<(usize, usize)> { + self.ensure_history_retention_accounting(); + let marker_idx = self.messages.iter().position(Self::is_history_hidden_marker_message); + if self.history_retention_stats.total_dropped_messages == 0 { + if let Some(idx) = marker_idx { + self.remove_message_tracked(idx); + return Self::remap_anchor_for_remove(preserved_anchor, idx, self.messages.len()); + } + return preserved_anchor; + } + + let marker_text = Self::history_hidden_marker_text( + self.history_retention_stats.total_dropped_messages, + self.history_retention_stats.total_dropped_bytes, + ); + + if let Some(idx) = marker_idx { + if let Some(MessageBlock::Text(block)) = + self.messages.get_mut(idx).and_then(|m| m.blocks.get_mut(0)) + && block.text != marker_text + { + block.text.clone_from(&marker_text); + block.markdown = IncrementalMarkdown::from_complete(&marker_text); + block.cache.invalidate(); + self.sync_render_cache_slot(idx, 0); + self.recompute_message_retained_bytes(idx); + self.invalidate_layout(InvalidationLevel::MessagesFrom(idx)); + } + return preserved_anchor; + } + + let insert_idx = usize::from( + self.messages.first().is_some_and(|msg| matches!(msg.role, MessageRole::Welcome)), + ); + self.insert_message_tracked( + insert_idx, + ChatMessage { + role: MessageRole::System(None), + blocks: vec![MessageBlock::Text(TextBlock::from_complete(&marker_text))], + usage: None, + }, + ); + Self::remap_anchor_for_insert(preserved_anchor, insert_idx) + } + + #[allow(clippy::cast_precision_loss)] + pub fn enforce_history_retention(&mut self) -> HistoryRetentionStats { + self.ensure_history_retention_accounting(); + let mut stats = HistoryRetentionStats::default(); + let max_bytes = self.history_retention.max_bytes.max(1); + let active_turn_owner = self.active_turn_assistant_idx(); + let mut preserved_anchor = self.viewport.capture_manual_scroll_anchor(); + stats.total_before_bytes = self.retained_history_bytes; + stats.total_after_bytes = stats.total_before_bytes; + + if stats.total_before_bytes > max_bytes { + let mut candidates = Vec::new(); + for (msg_idx, msg) in self.messages.iter().enumerate() { + if Self::is_history_hidden_marker_message(msg) + || Self::is_history_protected_message(msg) + || active_turn_owner == Some(msg_idx) + { + continue; + } + let bytes = self.message_retained_bytes.get(msg_idx).copied().unwrap_or(0); + if bytes == 0 { + continue; + } + candidates.push(HistoryDropCandidate { msg_idx, bytes }); + } + + let mut drop_candidates = Vec::new(); + for candidate in candidates { + if stats.total_after_bytes <= max_bytes { + break; + } + stats.total_after_bytes = stats.total_after_bytes.saturating_sub(candidate.bytes); + stats.dropped_bytes = stats.dropped_bytes.saturating_add(candidate.bytes); + stats.dropped_messages = stats.dropped_messages.saturating_add(1); + drop_candidates.push(candidate); + } + + if !drop_candidates.is_empty() { + preserved_anchor = self.apply_history_retention_drop( + &drop_candidates, + active_turn_owner, + preserved_anchor, + ); + self.rebuild_tool_indices_and_terminal_refs(); + self.viewport.sync_message_count(self.messages.len()); + if let Some((anchor_idx, anchor_offset)) = preserved_anchor { + self.viewport.preserve_scroll_anchor( + LayoutRemeasureReason::MessagesFrom, + anchor_idx, + anchor_offset, + ); + } + self.invalidate_layout(InvalidationLevel::MessagesFrom(0)); + self.needs_redraw = true; + } + } + + self.history_retention_stats.total_before_bytes = stats.total_before_bytes; + self.history_retention_stats.total_dropped_messages = self + .history_retention_stats + .total_dropped_messages + .saturating_add(stats.dropped_messages); + self.history_retention_stats.total_dropped_bytes = + self.history_retention_stats.total_dropped_bytes.saturating_add(stats.dropped_bytes); + + preserved_anchor = self.upsert_history_hidden_marker(preserved_anchor); + self.viewport.sync_message_count(self.messages.len()); + if let Some((anchor_idx, anchor_offset)) = preserved_anchor { + self.viewport.preserve_scroll_anchor( + LayoutRemeasureReason::MessagesFrom, + anchor_idx, + anchor_offset, + ); + } + + stats.total_after_bytes = self.retained_history_bytes; + self.history_retention_stats.total_after_bytes = stats.total_after_bytes; + self.history_retention_stats.dropped_messages = stats.dropped_messages; + self.history_retention_stats.dropped_bytes = stats.dropped_bytes; + + stats.total_dropped_messages = self.history_retention_stats.total_dropped_messages; + stats.total_dropped_bytes = self.history_retention_stats.total_dropped_bytes; + + crate::perf::mark_with("history::bytes_before", "bytes", stats.total_before_bytes); + crate::perf::mark_with("history::bytes_after", "bytes", stats.total_after_bytes); + crate::perf::mark_with("history::dropped_messages", "count", stats.dropped_messages); + crate::perf::mark_with("history::dropped_bytes", "bytes", stats.dropped_bytes); + crate::perf::mark_with("history::total_dropped", "count", stats.total_dropped_messages); + + stats + } + + fn apply_history_retention_drop( + &mut self, + drop_candidates: &[HistoryDropCandidate], + active_turn_owner: Option, + preserved_anchor: Option<(usize, usize)>, + ) -> Option<(usize, usize)> { + let drop_set: HashSet = + drop_candidates.iter().map(|candidate| candidate.msg_idx).collect(); + + let mut retained = Vec::with_capacity(self.messages.len().saturating_sub(drop_set.len())); + let mut retained_bytes = Vec::with_capacity(retained.capacity()); + let old_messages = std::mem::take(&mut self.messages); + let old_bytes = std::mem::take(&mut self.message_retained_bytes); + let mut old_to_new = vec![None; old_messages.len()]; + let mut remapped_active_turn_owner = None; + self.retained_history_bytes = 0; + for (msg_idx, (msg, bytes)) in old_messages.into_iter().zip(old_bytes).enumerate() { + if !drop_set.contains(&msg_idx) { + if active_turn_owner == Some(msg_idx) { + remapped_active_turn_owner = Some(retained.len()); + } + old_to_new[msg_idx] = Some(retained.len()); + self.retained_history_bytes = self.retained_history_bytes.saturating_add(bytes); + retained.push(msg); + retained_bytes.push(bytes); + } + } + self.messages = retained; + self.message_retained_bytes = retained_bytes; + self.active_turn_assistant_message_idx = remapped_active_turn_owner; + + let (anchor_idx, anchor_offset) = preserved_anchor?; + if let Some(new_idx) = old_to_new.get(anchor_idx).copied().flatten() { + return Some((new_idx, anchor_offset)); + } + + let fallback_old_idx = ((anchor_idx + 1)..old_to_new.len()) + .find(|&idx| old_to_new[idx].is_some()) + .or_else(|| (0..anchor_idx).rev().find(|&idx| old_to_new[idx].is_some()))?; + old_to_new[fallback_old_idx].map(|new_idx| (new_idx, 0)) + } +} diff --git a/claude-code-rust/src/app/state/messages.rs b/claude-code-rust/src/app/state/messages.rs new file mode 100644 index 0000000..1d6f4cf --- /dev/null +++ b/claude-code-rust/src/app/state/messages.rs @@ -0,0 +1,303 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::block_cache::BlockCache; +use super::tool_call_info::ToolCallInfo; +use super::types::{MessageUsage, RecentSessionInfo}; +use ratatui::style::Color; +use ratatui::text::Line; +use std::ops::Range; + +pub struct ChatMessage { + pub role: MessageRole, + pub blocks: Vec, + pub usage: Option, +} + +impl ChatMessage { + #[must_use] + pub fn welcome(model_name: &str, cwd: &str) -> Self { + Self::welcome_with_recent(model_name, cwd, &[]) + } + + #[must_use] + pub fn welcome_with_recent( + model_name: &str, + cwd: &str, + recent_sessions: &[RecentSessionInfo], + ) -> Self { + Self { + role: MessageRole::Welcome, + blocks: vec![MessageBlock::Welcome(WelcomeBlock { + model_name: model_name.to_owned(), + cwd: cwd.to_owned(), + recent_sessions: recent_sessions.to_vec(), + cache: BlockCache::default(), + })], + usage: None, + } + } +} + +/// Text holder for a single message block's markdown source. +/// +/// Block splitting for streaming text is handled at the message construction +/// level. Within a block, this type keeps stable paragraph-sized prefixes cached +/// so only the active tail needs to be re-rendered while streaming continues. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct MarkdownRenderKey { + pub width: u16, + pub bg: Option, + pub preserve_newlines: bool, +} + +#[derive(Default)] +struct MarkdownChunk { + range: Range, + rendered: Option>>, + render_key: Option, + dirty: bool, +} + +impl MarkdownChunk { + fn new(range: Range) -> Self { + Self { range, rendered: None, render_key: None, dirty: true } + } +} + +#[derive(Default)] +pub struct IncrementalMarkdown { + text: String, + chunks: Vec, +} + +impl IncrementalMarkdown { + /// Create from existing full text (e.g. user messages, connection errors). + /// Treats the entire text as one block source. + #[must_use] + pub fn from_complete(text: &str) -> Self { + let mut markdown = Self::default(); + markdown.append(text); + markdown + } + + /// Append a streaming text chunk. + pub fn append(&mut self, chunk: &str) { + if chunk.is_empty() { + return; + } + self.text.push_str(chunk); + if let Some(last) = self.chunks.last_mut() { + last.range.end = self.text.len(); + last.dirty = true; + last.rendered = None; + last.render_key = None; + } else { + self.chunks.push(MarkdownChunk::new(0..self.text.len())); + } + self.split_tail_chunks(); + } + + /// Get the full source text. + #[must_use] + pub fn full_text(&self) -> String { + self.text.clone() + } + + /// Allocated capacity of the internal text buffer in bytes. + #[must_use] + pub fn text_capacity(&self) -> usize { + self.text.capacity() + } + + /// Render this block source via the provided markdown renderer. + /// `render_fn` converts a markdown source string into `Vec`. + pub(crate) fn lines( + &mut self, + render_key: MarkdownRenderKey, + render_fn: &impl Fn(&str) -> Vec>, + ) -> Vec> { + self.ensure_rendered(render_key, render_fn); + + let mut rendered = Vec::new(); + for chunk in &self.chunks { + if let Some(lines) = &chunk.rendered { + rendered.extend(lines.iter().cloned()); + } + } + rendered + } + + pub fn invalidate_renders(&mut self) { + for chunk in &mut self.chunks { + chunk.dirty = true; + chunk.rendered = None; + chunk.render_key = None; + } + } + + pub(crate) fn ensure_rendered( + &mut self, + render_key: MarkdownRenderKey, + render_fn: &impl Fn(&str) -> Vec>, + ) { + for idx in 0..self.chunks.len() { + let needs_render = { + let chunk = &self.chunks[idx]; + chunk.dirty || chunk.rendered.is_none() || chunk.render_key != Some(render_key) + }; + if !needs_render { + continue; + } + + let range = self.chunks[idx].range.clone(); + let rendered = render_fn(&self.text[range]); + let chunk = &mut self.chunks[idx]; + chunk.rendered = Some(rendered); + chunk.render_key = Some(render_key); + chunk.dirty = false; + } + } + + fn split_tail_chunks(&mut self) { + loop { + let Some(last_idx) = self.chunks.len().checked_sub(1) else { + break; + }; + let range = self.chunks[last_idx].range.clone(); + let Some(split_at_rel) = find_first_stable_split(&self.text[range.clone()]) else { + break; + }; + let split_at = range.start + split_at_rel; + if split_at <= range.start || split_at >= range.end { + break; + } + + self.chunks[last_idx] = MarkdownChunk::new(range.start..split_at); + self.chunks.push(MarkdownChunk::new(split_at..range.end)); + } + } +} + +fn find_first_stable_split(text: &str) -> Option { + let mut in_fenced_code = false; + let mut saw_nonblank = false; + let mut blank_run_end = None; + let mut offset = 0usize; + + for line in text.split_inclusive('\n') { + offset += line.len(); + let trimmed = line.trim_end_matches('\n').trim(); + let is_fence = trimmed.starts_with("```") || trimmed.starts_with("~~~"); + if is_fence { + in_fenced_code = !in_fenced_code; + } + + let is_blank = trimmed.is_empty(); + if !in_fenced_code && is_blank { + if saw_nonblank { + blank_run_end = Some(offset); + } + continue; + } + + if let Some(boundary) = blank_run_end.take() + && boundary < text.len() + { + return Some(boundary); + } + + if !is_blank { + saw_nonblank = true; + } + } + + None +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TextBlockSpacing { + #[default] + None, + ParagraphBreak, +} + +impl TextBlockSpacing { + #[must_use] + pub fn blank_lines(self) -> usize { + match self { + Self::None => 0, + Self::ParagraphBreak => 1, + } + } +} + +pub struct TextBlock { + pub text: String, + pub cache: BlockCache, + pub markdown: IncrementalMarkdown, + /// Explicit visual spacing after this block. + /// + /// This is used when streaming splits one logical assistant message into + /// multiple cached blocks at paragraph boundaries. Rendering consumes this + /// metadata directly so spacing, height measurement, and scroll skipping all + /// agree without mutating source text. + pub trailing_spacing: TextBlockSpacing, +} + +impl TextBlock { + #[must_use] + pub fn new(text: String) -> Self { + Self { + markdown: IncrementalMarkdown::from_complete(&text), + text, + cache: BlockCache::default(), + trailing_spacing: TextBlockSpacing::None, + } + } + + #[must_use] + pub fn from_complete(text: &str) -> Self { + Self::new(text.to_owned()) + } + + #[must_use] + pub fn with_trailing_spacing(mut self, trailing_spacing: TextBlockSpacing) -> Self { + self.trailing_spacing = trailing_spacing; + self + } + + #[must_use] + pub fn trailing_blank_lines(&self) -> usize { + self.trailing_spacing.blank_lines() + } +} + +/// Ordered content block - text and tool calls interleaved as they arrive. +pub enum MessageBlock { + Text(TextBlock), + ToolCall(Box), + Welcome(WelcomeBlock), +} + +#[derive(Debug)] +pub enum MessageRole { + User, + Assistant, + System(Option), + Welcome, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemSeverity { + Info, + Warning, + Error, +} + +pub struct WelcomeBlock { + pub model_name: String, + pub cwd: String, + pub recent_sessions: Vec, + pub cache: BlockCache, +} diff --git a/claude-code-rust/src/app/state/mod.rs b/claude-code-rust/src/app/state/mod.rs new file mode 100644 index 0000000..800dc95 --- /dev/null +++ b/claude-code-rust/src/app/state/mod.rs @@ -0,0 +1,3084 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +pub mod block_cache; +pub mod cache_metrics; +mod history_retention; +pub mod messages; +mod render_budget; +pub mod tool_call_info; +pub mod types; +pub mod viewport; + +// Re-export all public types so external `use crate::app::state::X` paths still work. +pub use block_cache::BlockCache; +pub use cache_metrics::CacheMetrics; +pub(crate) use messages::MarkdownRenderKey; +pub use messages::{ + ChatMessage, IncrementalMarkdown, MessageBlock, MessageRole, SystemSeverity, TextBlock, + TextBlockSpacing, WelcomeBlock, +}; +pub use tool_call_info::{ + InlinePermission, InlineQuestion, TerminalSnapshotMode, ToolCallInfo, is_execute_tool_name, +}; +pub use types::{ + AppStatus, CancelOrigin, ExtraUsage, HelpView, HistoryRetentionPolicy, HistoryRetentionStats, + LoginHint, McpState, MessageUsage, ModeInfo, ModeState, PasteSessionState, PendingCommandAck, + RecentSessionInfo, RenderCacheBudget, SUBAGENT_THINKING_DEBOUNCE, ScrollbarDragState, + SelectionKind, SelectionPoint, SelectionState, SessionUsageState, TodoItem, TodoStatus, + ToolCallScope, UsageSnapshot, UsageSourceKind, UsageSourceMode, UsageState, UsageWindow, +}; +pub use viewport::{ + ChatViewport, LayoutInvalidation, LayoutInvalidation as InvalidationLevel, + LayoutRemeasureReason, +}; + +use crate::agent::events::ClientEvent; +use crate::agent::model; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Instant; +use tokio::sync::mpsc; + +use super::config::ConfigState; +use super::dialog; +use super::focus::{FocusContext, FocusManager, FocusOwner, FocusTarget}; +use super::input::{InputSnapshot, InputState, parse_paste_placeholder_before_cursor}; +use super::mention; +use super::plugins::PluginsState; +use super::slash; +use super::subagent; +use super::trust::TrustState; +use super::view::ActiveView; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TerminalToolCallRef { + pub terminal_id: String, + pub msg_idx: usize, + pub block_idx: usize, +} + +impl TerminalToolCallRef { + #[must_use] + pub fn new(terminal_id: String, msg_idx: usize, block_idx: usize) -> Self { + Self { terminal_id, msg_idx, block_idx } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutocompleteKind { + Mention, + Slash, + Subagent, +} + +#[allow(clippy::struct_excessive_bools)] +pub struct App { + pub active_view: ActiveView, + pub config: ConfigState, + pub trust: TrustState, + pub settings_home_override: Option, + pub messages: Vec, + /// Cached approximate retained bytes for each message, parallel to `messages`. + pub message_retained_bytes: Vec, + /// Rolling total of `message_retained_bytes`. + pub retained_history_bytes: usize, + /// Single owner of all chat layout state: scroll, per-message heights, prefix sums. + pub viewport: ChatViewport, + pub input: InputState, + pub status: AppStatus, + /// Session id currently being resumed via `/resume`. + pub resuming_session_id: Option, + /// Spinner label shown while a slash command is in flight (`CommandPending`). + pub pending_command_label: Option, + /// Ack marker required to clear `CommandPending` for strict completion semantics. + pub pending_command_ack: Option, + pub should_quit: bool, + /// Optional fatal app error that should be surfaced at CLI boundary. + pub exit_error: Option, + pub session_id: Option, + /// Agent connection handle. `None` while connecting (before bridge is ready). + pub conn: Option>, + /// Monotonic session authority epoch used to ignore stale async view data. + pub session_scope_epoch: u64, + pub model_name: String, + /// True once the welcome banner has captured its one-time session model label. + pub welcome_model_resolved: bool, + pub cwd: String, + pub cwd_raw: String, + pub files_accessed: usize, + pub mode: Option, + /// Latest config options observed from bridge `config_option_update` events. + pub config_options: BTreeMap, + /// Login hint shown when authentication is required. Rendered above the input field. + pub login_hint: Option, + /// When true, the current/next turn completion should clear local conversation history. + /// Set by `/compact` once the command is accepted for bridge forwarding. + pub pending_compact_clear: bool, + /// Active help overlay view when `?` help is open. + pub help_view: HelpView, + /// Whether the help overlay is explicitly open. + pub help_open: bool, + /// Scroll/selection state for the Slash and Subagents help tabs. + pub help_dialog: dialog::DialogState, + /// Number of items that currently fit in the help viewport (updated each render). + /// Used by key handlers for accurate scroll step size. + pub help_visible_count: usize, + /// Tool call IDs with pending inline interactions, ordered by arrival. + /// The first entry is the focused interaction that receives keyboard input. + /// Up / Down arrow keys cycle focus through the list. + pub pending_interaction_ids: Vec, + /// Set when a cancel notification succeeds; consumed on `TurnComplete` + /// to render a red interruption hint in chat. + pub cancelled_turn_pending_hint: bool, + /// Origin of the in-flight cancellation request, if any. + pub pending_cancel_origin: Option, + /// Auto-submit the current input draft once cancellation transitions the app + /// back to `Ready`. + pub pending_auto_submit_after_cancel: bool, + pub event_tx: mpsc::UnboundedSender, + pub event_rx: mpsc::UnboundedReceiver, + pub spinner_frame: usize, + pub spinner_last_advance_at: Option, + /// Message index that owns the current main-assistant turn indicators. + pub active_turn_assistant_message_idx: Option, + /// Session-level preference for collapsing non-Execute tool call bodies. + /// Toggled by Ctrl+O and applied at render/layout time. + pub tools_collapsed: bool, + /// IDs of Task/Agent tool calls currently `InProgress` -- their children get hidden. + /// Use `insert_active_task()`, `remove_active_task()`. + pub active_task_ids: HashSet, + /// Tool scope keyed by tool call ID; used to distinguish main-agent from subagent tools. + pub tool_call_scopes: HashMap, + /// IDs of non Task/Agent subagent tool calls currently `InProgress`/`Pending`. + pub active_subagent_tool_ids: HashSet, + /// Timestamp when subagent entered an idle gap (no active child tool calls). + pub subagent_idle_since: Option, + /// Shared terminal process map - used to snapshot output on completion. + pub terminals: crate::agent::events::TerminalMap, + /// Force a full terminal clear on next render frame. + pub force_redraw: bool, + /// O(1) lookup: `tool_call_id` -> `(message_index, block_index)`. + /// Use `lookup_tool_call()`, `index_tool_call()`. + pub tool_call_index: HashMap, + /// Current todo list from Claude's `TodoWrite` tool calls. + pub todos: Vec, + /// Whether the todo panel is expanded (true) or shows compact status line (false). + /// Toggled by Ctrl+T. + pub show_todo_panel: bool, + /// Scroll offset for the expanded todo panel (capped at 5 visible lines). + pub todo_scroll: usize, + /// Selected todo index used for keyboard navigation in the open todo panel. + pub todo_selected: usize, + /// Focus manager for directional/navigation key ownership. + pub focus: FocusManager, + /// Commands advertised by the agent via `AvailableCommandsUpdate`. + pub available_commands: Vec, + /// Plugin inventory and UI state for the Config > Plugins view. + pub plugins: PluginsState, + /// Subagents advertised by the agent via `AvailableAgentsUpdate`. + pub available_agents: Vec, + /// Models advertised by the agent SDK for the active session. + pub available_models: Vec, + /// Recently persisted session IDs discovered at startup. + pub recent_sessions: Vec, + /// Last known frame area (for mouse selection mapping). + pub cached_frame_area: ratatui::layout::Rect, + /// Current selection state for mouse-based selection. + pub selection: Option, + /// Active scrollbar drag state while left mouse button is held on the rail. + pub scrollbar_drag: Option, + /// Cached rendered chat lines for selection/copy. + pub rendered_chat_lines: Vec, + /// Area where chat content was rendered (for selection mapping). + pub rendered_chat_area: ratatui::layout::Rect, + /// Cached rendered input lines for selection/copy. + pub rendered_input_lines: Vec, + /// Area where input content was rendered (for selection mapping). + pub rendered_input_area: ratatui::layout::Rect, + /// Active `@` file mention autocomplete state. + pub mention: Option, + /// Active slash-command autocomplete state. + pub slash: Option, + /// Active subagent autocomplete state (`&name`). + pub subagent: Option, + /// Deferred plain-Enter submit. Stores the exact input state from before the + /// Enter key so submission can restore and use the original draft text. + /// + /// If another editing-like event or a paste payload arrives in the same + /// drain cycle, this is cleared and no submit occurs. + pub pending_submit: Option, + /// Timing-based paste burst detector. Detects rapid character streams + /// (paste delivered as individual key events) and buffers them into a + /// single paste payload. Fallback for terminals without bracketed paste. + pub paste_burst: super::paste_burst::PasteBurstDetector, + /// Buffered `Event::Paste` payload for this drain cycle. + /// Some terminals split one clipboard paste into multiple chunks; we merge + /// them and apply placeholder threshold to the merged content once per cycle. + pub pending_paste_text: String, + /// Pending paste session metadata for the currently queued `Event::Paste` payload. + pub pending_paste_session: Option, + /// Most recent active placeholder paste session, used for safe chunk continuation. + pub active_paste_session: Option, + /// Monotonic counter for paste session identifiers. + pub next_paste_session_id: u64, + /// Cached todo compact line (invalidated on `set_todos()`). + pub cached_todo_compact: Option>, + /// Current git branch (refreshed on focus gain + turn complete). + pub git_branch: Option, + /// Optional startup update-check hint rendered at the footer's right edge. + pub update_check_hint: Option, + /// Session-wide usage and cost telemetry from the bridge. + pub session_usage: SessionUsageState, + /// Config > Usage snapshot and refresh lifecycle. + pub usage: UsageState, + /// Config > MCP live server snapshot and refresh lifecycle. + pub mcp: McpState, + /// Fast mode state telemetry from the SDK. + pub fast_mode_state: model::FastModeState, + /// Latest rate-limit telemetry from the SDK. + pub last_rate_limit_update: Option, + /// True while the SDK reports active compaction. + pub is_compacting: bool, + /// Account info from the bridge status snapshot (email, org, subscription). + pub account_info: Option, + + /// Indexed terminal tool calls for per-frame terminal snapshot updates. + /// Avoids O(n*m) scan of all messages/blocks every frame. + pub terminal_tool_calls: Vec, + /// Membership index for `terminal_tool_calls`, used to avoid linear duplicate checks. + pub terminal_tool_call_membership: HashSet, + /// Dirty flag: skip `terminal.draw()` when nothing changed since last frame. + pub needs_redraw: bool, + /// Central notification manager (bell + desktop toast when unfocused). + pub notifications: super::notify::NotificationManager, + /// Performance logger. Present only when built with `--features perf`. + /// Taken out (`Option::take`) during render, used, then put back to avoid + /// borrow conflicts with `&mut App`. + pub perf: Option, + /// Global in-memory budget for rendered block caches (message + tool + welcome). + pub render_cache_budget: RenderCacheBudget, + /// Cached render-cache slot metadata parallel to `messages[*].blocks[*]`. + pub(crate) render_cache_slots: Vec>, + /// Rolling total of cached render bytes across all blocks. + pub(crate) render_cache_total_bytes: usize, + /// Rolling total of cached render bytes currently excluded from the budget. + pub(crate) render_cache_protected_bytes: usize, + /// Evictable cached blocks ordered by LRU and size tie-breaker. + pub(crate) render_cache_evictable: BTreeSet, + /// Last message index currently protected as the streaming tail, if any. + pub(crate) render_cache_tail_msg_idx: Option, + /// Byte budget for source conversation history retained in memory. + pub history_retention: HistoryRetentionPolicy, + /// Last history-retention enforcement statistics. + pub history_retention_stats: HistoryRetentionStats, + /// Cross-cutting cache metrics accumulator (enforcement counts, watermarks, rate limits). + pub cache_metrics: CacheMetrics, + /// Smoothed frames-per-second (EMA of presented frame cadence). + pub fps_ema: Option, + /// Timestamp of the previous presented frame. + pub last_frame_at: Option, + pub startup_connection_requested: bool, + pub connection_started: bool, + pub startup_bridge_script: Option, + pub startup_resume_id: Option, + pub startup_resume_requested: bool, +} + +impl App { + /// Queue a paste payload for drain-cycle finalization. + /// + /// This is fed by paste payloads captured from terminal events. + pub fn queue_paste_text(&mut self, text: &str) { + if text.is_empty() { + return; + } + tracing::debug!( + text = %debug_paste_text(text), + pending_len = self.pending_paste_text.chars().count(), + had_pending_submit = self.pending_submit.is_some(), + "paste_queue: enqueue" + ); + self.pending_submit = None; + if self.pending_paste_text.is_empty() { + let continued_session = self.active_paste_session.and_then(|session| { + let current_line = self.input.lines().get(self.input.cursor_row())?; + let idx = + parse_paste_placeholder_before_cursor(current_line, self.input.cursor_col())?; + (session.placeholder_index == Some(idx)).then_some(session) + }); + self.pending_paste_session = Some(continued_session.unwrap_or_else(|| { + let id = self.next_paste_session_id; + self.next_paste_session_id = self.next_paste_session_id.saturating_add(1); + PasteSessionState { + id, + start: SelectionPoint { + row: self.input.cursor_row(), + col: self.input.cursor_col(), + }, + placeholder_index: None, + } + })); + if let Some(session) = self.pending_paste_session { + tracing::debug!( + session_id = session.id, + start_row = session.start.row, + start_col = session.start.col, + placeholder_index = ?session.placeholder_index, + "paste_queue: opened session" + ); + } + } + self.pending_paste_text.push_str(text); + tracing::debug!( + merged = %debug_paste_text(&self.pending_paste_text), + merged_len = self.pending_paste_text.chars().count(), + "paste_queue: merged pending text" + ); + } + + /// Mark one presented frame at `now`, updating smoothed FPS. + pub fn mark_frame_presented(&mut self, now: Instant) { + let Some(prev) = self.last_frame_at.replace(now) else { + return; + }; + let dt = now.saturating_duration_since(prev).as_secs_f32(); + if dt <= f32::EPSILON { + return; + } + let fps = (1.0 / dt).clamp(0.0, 240.0); + self.fps_ema = Some(match self.fps_ema { + Some(current) => current * 0.9 + fps * 0.1, + None => fps, + }); + } + + #[must_use] + pub fn is_project_trusted(&self) -> bool { + self.trust.is_trusted() + } + + #[must_use] + pub fn frame_fps(&self) -> Option { + self.fps_ema + } + + /// Ensure the synthetic welcome message exists at index 0. + pub fn ensure_welcome_message(&mut self) { + if self.messages.first().is_some_and(|m| matches!(m.role, MessageRole::Welcome)) { + return; + } + self.insert_message_tracked( + 0, + ChatMessage::welcome_with_recent( + self.welcome_model_display_name(), + &self.cwd, + &self.recent_sessions, + ), + ); + self.welcome_model_resolved = self.model_name_is_authoritative(); + } + + fn model_name_is_authoritative(&self) -> bool { + let model_name = self.model_name.trim(); + if model_name.is_empty() || model_name == "Connecting..." { + return false; + } + if model_name != "default" { + return true; + } + matches!( + crate::app::config::store::model(&self.config.committed_settings_document), + Ok(Some(configured_model)) if configured_model.trim() == "default" + ) + } + + #[must_use] + pub fn model_display_name(&self) -> &str { + let model_name = self.model_name.trim(); + if self.session_id.is_none() + && (model_name.is_empty() || model_name == "Connecting..." || model_name == "default") + { + "Connecting..." + } else if model_name.is_empty() || model_name == "Connecting..." { + "default" + } else { + &self.model_name + } + } + + #[must_use] + fn welcome_model_display_name(&self) -> &str { + self.model_display_name() + } + + /// Update the welcome message's model name once, when the session model becomes authoritative. + pub fn update_welcome_model_once(&mut self) { + if self.welcome_model_resolved { + return; + } + let welcome_model = self.welcome_model_display_name().to_owned(); + let model_is_authoritative = self.model_name_is_authoritative(); + let Some(first) = self.messages.first_mut() else { + return; + }; + if !matches!(first.role, MessageRole::Welcome) { + return; + } + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first_mut() else { + return; + }; + if welcome.model_name != welcome_model { + welcome.model_name = welcome_model; + welcome.cache.invalidate(); + self.sync_render_cache_slot(0, 0); + self.recompute_message_retained_bytes(0); + self.invalidate_layout(InvalidationLevel::MessagesFrom(0)); + } + if model_is_authoritative { + self.welcome_model_resolved = true; + } + } + + /// Update the welcome message with latest discovered recent sessions. + pub fn sync_welcome_recent_sessions(&mut self) { + let Some(first) = self.messages.first_mut() else { + return; + }; + if !matches!(first.role, MessageRole::Welcome) { + return; + } + let Some(MessageBlock::Welcome(welcome)) = first.blocks.first_mut() else { + return; + }; + welcome.recent_sessions.clone_from(&self.recent_sessions); + welcome.cache.invalidate(); + self.sync_render_cache_slot(0, 0); + self.recompute_message_retained_bytes(0); + self.invalidate_layout(InvalidationLevel::MessagesFrom(0)); + } + + /// Track a Task/Agent tool call as active (in-progress subagent). + pub fn insert_active_task(&mut self, id: String) { + self.active_task_ids.insert(id); + } + + /// Remove a Task/Agent tool call from the active set (completed/failed). + pub fn remove_active_task(&mut self, id: &str) { + self.active_task_ids.remove(id); + } + + pub fn register_tool_call_scope(&mut self, id: String, scope: ToolCallScope) { + self.tool_call_scopes.insert(id, scope); + } + + #[must_use] + pub fn tool_call_scope(&self, id: &str) -> Option { + self.tool_call_scopes.get(id).copied() + } + + #[must_use] + pub(crate) fn tracked_terminal_id_for_tool(tc: &ToolCallInfo) -> Option { + (tc.is_execute_tool() + && matches!( + tc.status, + model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress + )) + .then(|| tc.terminal_id.clone()) + .flatten() + } + + pub fn mark_subagent_tool_started(&mut self, id: &str) { + self.active_subagent_tool_ids.insert(id.to_owned()); + self.subagent_idle_since = None; + } + + pub fn mark_subagent_tool_finished(&mut self, id: &str, now: Instant) { + self.active_subagent_tool_ids.remove(id); + self.refresh_subagent_idle_since(now); + } + + pub fn refresh_subagent_idle_since(&mut self, now: Instant) { + if self.active_task_ids.is_empty() || !self.active_subagent_tool_ids.is_empty() { + self.subagent_idle_since = None; + return; + } + if self.subagent_idle_since.is_none() { + self.subagent_idle_since = Some(now); + } + } + + #[must_use] + pub fn should_show_subagent_thinking(&self, now: Instant) -> bool { + if self.active_task_ids.is_empty() || !self.active_subagent_tool_ids.is_empty() { + return false; + } + self.subagent_idle_since + .is_some_and(|since| now.saturating_duration_since(since) >= SUBAGENT_THINKING_DEBOUNCE) + } + + pub fn clear_tool_scope_tracking(&mut self) { + self.tool_call_scopes.clear(); + self.active_task_ids.clear(); + self.active_subagent_tool_ids.clear(); + self.subagent_idle_since = None; + } + + /// Look up the (`message_index`, `block_index`) for a tool call ID. + #[must_use] + pub fn lookup_tool_call(&self, id: &str) -> Option<(usize, usize)> { + self.tool_call_index.get(id).copied() + } + + /// Register a tool call's position in the message/block arrays. + pub fn index_tool_call(&mut self, id: String, msg_idx: usize, block_idx: usize) { + self.tool_call_index.insert(id, (msg_idx, block_idx)); + } + + pub(crate) fn sync_terminal_tool_call( + &mut self, + terminal_id: String, + msg_idx: usize, + block_idx: usize, + ) { + let desired = TerminalToolCallRef::new(terminal_id, msg_idx, block_idx); + if self.terminal_tool_call_membership.contains(&desired) { + return; + } + self.untrack_terminal_tool_call(msg_idx, block_idx); + self.terminal_tool_call_membership.insert(desired.clone()); + self.terminal_tool_calls.push(desired); + } + + pub(crate) fn untrack_terminal_tool_call(&mut self, msg_idx: usize, block_idx: usize) { + let removed: Vec<_> = self + .terminal_tool_calls + .iter() + .filter(|entry| entry.msg_idx == msg_idx && entry.block_idx == block_idx) + .cloned() + .collect(); + if removed.is_empty() { + return; + } + self.terminal_tool_calls + .retain(|entry| entry.msg_idx != msg_idx || entry.block_idx != block_idx); + for entry in removed { + self.terminal_tool_call_membership.remove(&entry); + } + } + + pub(crate) fn clear_terminal_tool_call_tracking(&mut self) { + self.terminal_tool_calls.clear(); + self.terminal_tool_call_membership.clear(); + } + + pub(crate) fn sync_after_message_blocks_changed(&mut self, msg_idx: usize) { + self.note_render_cache_structure_changed(); + self.sync_render_cache_message(msg_idx); + self.recompute_message_retained_bytes(msg_idx); + } + + /// Invalidate message layout caches at the given level. + /// + /// Single entry point for all layout invalidation. Replaces the former + /// `mark_message_layout_dirty` / `mark_all_message_layout_dirty` methods. + pub fn invalidate_layout(&mut self, level: LayoutInvalidation) { + match level { + LayoutInvalidation::MessageChanged(idx) => { + self.viewport.invalidate_message(idx); + } + LayoutInvalidation::MessagesFrom(idx) => { + self.viewport.invalidate_messages_from(idx); + } + LayoutInvalidation::Global => { + if self.messages.is_empty() { + return; + } + self.viewport.invalidate_all_messages(LayoutRemeasureReason::Global); + self.viewport.bump_layout_generation(); + } + LayoutInvalidation::Resize => { + // Resize is handled by viewport.on_frame(). This arm exists + // for exhaustiveness; production code should not reach it. + debug_assert!(false, "Resize should not be dispatched through invalidate_layout"); + } + } + } + + pub(crate) fn invalidate_message_set(&mut self, indices: I) + where + I: IntoIterator, + { + let unique: BTreeSet<_> = + indices.into_iter().filter(|&idx| idx < self.messages.len()).collect(); + for idx in unique { + self.viewport.invalidate_message(idx); + } + } + + /// Enforce history retention and record metrics. + /// + /// Wrapper around [`enforce_history_retention`] that feeds the returned stats + /// into `CacheMetrics` and emits rate-limited structured tracing. Call this + /// instead of `enforce_history_retention()` at all non-test call sites. + pub fn enforce_history_retention_tracked(&mut self) { + let stats = self.enforce_history_retention(); + let should_log = + self.cache_metrics.record_history_enforcement(&stats, self.history_retention); + if should_log { + let snap = cache_metrics::build_snapshot( + &self.render_cache_budget, + &self.history_retention_stats, + self.history_retention, + &self.cache_metrics, + &self.viewport, + 0, // entry_count not needed for history-only log + 0, + stats.dropped_messages, + 0, // protected_bytes not relevant for history-only log + ); + cache_metrics::emit_history_metrics(&snap); + } + } + + /// Force-finish any lingering in-progress tool calls. + /// Returns the number of tool calls that were transitioned. + pub fn finalize_in_progress_tool_calls(&mut self, new_status: model::ToolCallStatus) -> usize { + let mut changed = 0usize; + let mut cleared_interaction = false; + let mut changed_message_indices = Vec::new(); + let mut changed_slots = Vec::new(); + let mut detached_terminal = false; + + for (msg_idx, msg) in self.messages.iter_mut().enumerate() { + for (block_idx, block) in msg.blocks.iter_mut().enumerate() { + if let MessageBlock::ToolCall(tc) = block { + let tc = tc.as_mut(); + if matches!( + tc.status, + model::ToolCallStatus::InProgress | model::ToolCallStatus::Pending + ) { + tc.status = new_status; + tc.mark_tool_call_layout_dirty(); + changed_slots.push((msg_idx, block_idx)); + if tc.pending_permission.take().is_some() { + cleared_interaction = true; + } + if tc.pending_question.take().is_some() { + cleared_interaction = true; + } + if tc.is_execute_tool() && tc.terminal_id.take().is_some() { + detached_terminal = true; + } + if changed_message_indices.last().copied() != Some(msg_idx) { + changed_message_indices.push(msg_idx); + } + changed += 1; + } + } + } + } + + if detached_terminal { + self.rebuild_tool_indices_and_terminal_refs(); + } + + for (msg_idx, block_idx) in changed_slots { + self.sync_render_cache_slot(msg_idx, block_idx); + } + + for msg_idx in changed_message_indices.iter().copied() { + self.recompute_message_retained_bytes(msg_idx); + } + + if changed > 0 || cleared_interaction { + self.invalidate_message_set(changed_message_indices.iter().copied()); + self.pending_interaction_ids.clear(); + self.release_focus_target(FocusTarget::Permission); + } + + changed + } + + /// Clear any inline permission/question UI still attached to tool calls. + /// Returns the number of tool call blocks that changed. + pub fn clear_inline_tool_interactions(&mut self) -> usize { + let mut changed = 0usize; + let mut changed_message_indices = Vec::new(); + let mut changed_slots = Vec::new(); + + for (msg_idx, msg) in self.messages.iter_mut().enumerate() { + for (block_idx, block) in msg.blocks.iter_mut().enumerate() { + let MessageBlock::ToolCall(tc) = block else { + continue; + }; + let tc = tc.as_mut(); + let mut block_changed = false; + if tc.pending_permission.take().is_some() { + block_changed = true; + } + if tc.pending_question.take().is_some() { + block_changed = true; + } + if !block_changed { + continue; + } + tc.mark_tool_call_layout_dirty(); + changed_slots.push((msg_idx, block_idx)); + if changed_message_indices.last().copied() != Some(msg_idx) { + changed_message_indices.push(msg_idx); + } + changed += 1; + } + } + + for (msg_idx, block_idx) in changed_slots { + self.sync_render_cache_slot(msg_idx, block_idx); + } + + for msg_idx in changed_message_indices.iter().copied() { + self.recompute_message_retained_bytes(msg_idx); + } + + if changed > 0 { + self.invalidate_message_set(changed_message_indices.iter().copied()); + } + + if changed > 0 || !self.pending_interaction_ids.is_empty() { + self.pending_interaction_ids.clear(); + self.release_focus_target(FocusTarget::Permission); + } + + changed + } + + /// Clear runtime-only turn tracking while preserving the message history itself. + pub fn finalize_turn_runtime_artifacts(&mut self, new_status: model::ToolCallStatus) { + let _ = self.finalize_in_progress_tool_calls(new_status); + let _ = self.clear_inline_tool_interactions(); + self.clear_tool_scope_tracking(); + } + + /// Build a minimal `App` for unit/integration tests. + /// All fields get sensible defaults; the `mpsc` channel is wired up internally. + #[doc(hidden)] + #[must_use] + #[allow(clippy::too_many_lines)] + pub fn test_default() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + Self { + active_view: ActiveView::Chat, + config: ConfigState::default(), + trust: TrustState::default(), + settings_home_override: None, + messages: Vec::new(), + message_retained_bytes: Vec::new(), + retained_history_bytes: 0, + viewport: ChatViewport::new(), + input: InputState::new(), + status: AppStatus::Ready, + resuming_session_id: None, + pending_command_label: None, + pending_command_ack: None, + should_quit: false, + exit_error: None, + session_id: None, + conn: None, + session_scope_epoch: 0, + model_name: "test-model".into(), + welcome_model_resolved: true, + cwd: "/test".into(), + cwd_raw: "/test".into(), + files_accessed: 0, + mode: None, + config_options: BTreeMap::new(), + login_hint: None, + pending_compact_clear: false, + help_view: HelpView::Keys, + help_open: false, + help_dialog: dialog::DialogState::default(), + help_visible_count: 0, + pending_interaction_ids: Vec::new(), + cancelled_turn_pending_hint: false, + pending_cancel_origin: None, + pending_auto_submit_after_cancel: false, + event_tx: tx, + event_rx: rx, + spinner_frame: 0, + spinner_last_advance_at: None, + active_turn_assistant_message_idx: None, + tools_collapsed: false, + active_task_ids: HashSet::default(), + tool_call_scopes: HashMap::default(), + active_subagent_tool_ids: HashSet::default(), + subagent_idle_since: None, + terminals: std::rc::Rc::default(), + force_redraw: false, + tool_call_index: HashMap::default(), + todos: Vec::new(), + show_todo_panel: false, + todo_scroll: 0, + todo_selected: 0, + focus: FocusManager::default(), + available_commands: Vec::new(), + plugins: PluginsState::default(), + available_agents: Vec::new(), + available_models: Vec::new(), + recent_sessions: Vec::new(), + cached_frame_area: ratatui::layout::Rect::default(), + selection: None, + scrollbar_drag: None, + rendered_chat_lines: Vec::new(), + rendered_chat_area: ratatui::layout::Rect::default(), + rendered_input_lines: Vec::new(), + rendered_input_area: ratatui::layout::Rect::default(), + mention: None, + slash: None, + subagent: None, + pending_submit: None, + paste_burst: super::paste_burst::PasteBurstDetector::new(), + pending_paste_text: String::new(), + pending_paste_session: None, + active_paste_session: None, + next_paste_session_id: 1, + cached_todo_compact: None, + git_branch: None, + update_check_hint: None, + session_usage: SessionUsageState::default(), + usage: UsageState::default(), + mcp: McpState::default(), + fast_mode_state: model::FastModeState::Off, + last_rate_limit_update: None, + is_compacting: false, + account_info: None, + terminal_tool_calls: Vec::new(), + terminal_tool_call_membership: HashSet::new(), + needs_redraw: true, + notifications: super::notify::NotificationManager::new(), + perf: None, + render_cache_budget: RenderCacheBudget::default(), + render_cache_slots: Vec::new(), + render_cache_total_bytes: 0, + render_cache_protected_bytes: 0, + render_cache_evictable: BTreeSet::new(), + render_cache_tail_msg_idx: None, + history_retention: HistoryRetentionPolicy::default(), + history_retention_stats: HistoryRetentionStats::default(), + cache_metrics: CacheMetrics::default(), + fps_ema: None, + last_frame_at: None, + startup_connection_requested: false, + connection_started: false, + startup_bridge_script: None, + startup_resume_id: None, + startup_resume_requested: false, + } + } + + /// Detect the current git branch. + pub fn refresh_git_branch(&mut self) { + let new_branch = std::process::Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(&self.cwd_raw) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + let s = String::from_utf8_lossy(&o.stdout).trim().to_owned(); + if s.is_empty() { None } else { Some(s) } + } else { + None + } + }); + if new_branch != self.git_branch { + self.git_branch = new_branch; + } + } + + /// Resolve the effective focus owner for Up/Down and other directional keys. + #[must_use] + pub fn focus_owner(&self) -> FocusOwner { + self.focus.owner(self.focus_context()) + } + + #[must_use] + pub fn active_turn_assistant_idx(&self) -> Option { + self.active_turn_assistant_message_idx.filter(|&idx| { + self.messages.get(idx).is_some_and(|msg| matches!(msg.role, MessageRole::Assistant)) + }) + } + + pub fn bind_active_turn_assistant(&mut self, idx: usize) { + self.active_turn_assistant_message_idx = self + .messages + .get(idx) + .is_some_and(|msg| matches!(msg.role, MessageRole::Assistant)) + .then_some(idx); + } + + pub fn bind_active_turn_assistant_to_tail(&mut self) { + if let Some(idx) = self.messages.len().checked_sub(1) { + self.bind_active_turn_assistant(idx); + } else { + self.clear_active_turn_assistant(); + } + } + + pub fn clear_active_turn_assistant(&mut self) { + self.active_turn_assistant_message_idx = None; + } + + pub fn bump_session_scope_epoch(&mut self) { + self.session_scope_epoch = self.session_scope_epoch.saturating_add(1); + } + + pub fn clear_session_runtime_identity(&mut self) { + self.session_id = None; + "Connecting...".clone_into(&mut self.model_name); + self.mode = None; + self.fast_mode_state = model::FastModeState::Off; + self.welcome_model_resolved = false; + } + + pub fn reconcile_trust_state_from_preferences_and_cwd(&mut self) { + let lookup = crate::app::trust::store::read_status( + &self.config.committed_preferences_document, + Path::new(&self.cwd_raw), + ); + self.trust.project_key = lookup.project_key; + self.trust.status = if lookup.trusted { + crate::app::trust::TrustStatus::Trusted + } else { + crate::app::trust::TrustStatus::Untrusted + }; + self.trust.selection = crate::app::trust::TrustSelection::Yes; + self.trust.last_error = self + .config + .preferences_path + .is_none() + .then(|| "Trust preferences path is not available".to_owned()); + } + + pub fn reconcile_runtime_from_persisted_settings_change(&mut self) { + self.welcome_model_resolved = false; + self.reconcile_trust_state_from_preferences_and_cwd(); + self.update_welcome_model_once(); + } + + pub(crate) fn shift_active_turn_assistant_for_insert(&mut self, idx: usize) { + if let Some(owner_idx) = self.active_turn_assistant_message_idx + && idx <= owner_idx + { + self.active_turn_assistant_message_idx = Some(owner_idx.saturating_add(1)); + } + } + + pub(crate) fn shift_active_turn_assistant_for_remove(&mut self, idx: usize) { + let Some(owner_idx) = self.active_turn_assistant_message_idx else { + return; + }; + self.active_turn_assistant_message_idx = match idx.cmp(&owner_idx) { + std::cmp::Ordering::Less => Some(owner_idx.saturating_sub(1)), + std::cmp::Ordering::Equal => None, + std::cmp::Ordering::Greater => Some(owner_idx), + }; + } + + #[must_use] + pub fn active_autocomplete_kind(&self) -> Option { + if self.mention.is_some() { + Some(AutocompleteKind::Mention) + } else if self.slash.is_some() { + Some(AutocompleteKind::Slash) + } else if self.subagent.is_some() { + Some(AutocompleteKind::Subagent) + } else { + None + } + } + + #[must_use] + pub fn is_help_active(&self) -> bool { + self.help_open + } + + #[must_use] + pub fn autocomplete_focus_available(&self) -> bool { + self.mention.as_ref().is_some_and(mention::MentionState::has_selectable_candidates) + || self.slash.is_some() + || self.subagent.is_some() + } + + pub fn rebuild_chat_focus_from_state(&mut self) { + if self.active_view != ActiveView::Chat { + return; + } + + self.normalize_focus_stack(); + + if self.pending_interaction_ids.is_empty() { + self.release_focus_target(FocusTarget::Permission); + } else { + self.claim_focus_target(FocusTarget::Permission); + } + + if self.autocomplete_focus_available() { + self.claim_focus_target(FocusTarget::Mention); + } else { + self.release_focus_target(FocusTarget::Mention); + } + + if self.is_help_active() + && self.pending_interaction_ids.is_empty() + && !self.autocomplete_focus_available() + { + self.claim_focus_target(FocusTarget::Help); + } else { + self.release_focus_target(FocusTarget::Help); + } + + self.normalize_focus_stack(); + } + + /// Claim key routing for a navigation target. + /// The latest claimant wins. + pub fn claim_focus_target(&mut self, target: FocusTarget) { + let context = self.focus_context(); + self.focus.claim(target, context); + } + + /// Release key routing claim for a navigation target. + pub fn release_focus_target(&mut self, target: FocusTarget) { + let context = self.focus_context(); + self.focus.release(target, context); + } + + /// Drop claims that are no longer valid for current state. + pub fn normalize_focus_stack(&mut self) { + let context = self.focus_context(); + self.focus.normalize(context); + } + + #[must_use] + fn focus_context(&self) -> FocusContext { + FocusContext::new( + self.show_todo_panel && !self.todos.is_empty(), + self.autocomplete_focus_available(), + !self.pending_interaction_ids.is_empty(), + ) + .with_help(self.is_help_active()) + } +} + +fn debug_paste_text(text: &str) -> String { + const MAX_CHARS: usize = 60; + let mut out = String::new(); + let mut iter = text.chars(); + for _ in 0..MAX_CHARS { + let Some(ch) = iter.next() else { + return out; + }; + out.extend(ch.escape_default()); + } + if iter.next().is_some() { + out.push_str("..."); + } + out +} + +#[cfg(test)] +mod tests { + // ===== + // TESTS: 26 + // ===== + + use super::*; + use crate::app::dialog; + use crate::app::slash::{SlashCandidate, SlashContext, SlashState}; + use pretty_assertions::assert_eq; + use ratatui::style::{Color, Style}; + use ratatui::text::{Line, Span}; + + // BlockCache + + #[test] + fn cache_default_returns_none() { + let cache = BlockCache::default(); + assert!(cache.get().is_none()); + } + + #[test] + fn cache_store_then_get() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("hello")]); + assert!(cache.get().is_some()); + assert_eq!(cache.get().unwrap().len(), 1); + } + + #[test] + fn cache_invalidate_then_get_returns_none() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("data")]); + cache.invalidate(); + assert!(cache.get().is_none()); + } + + // BlockCache + + #[test] + fn cache_store_after_invalidate() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("old")]); + cache.invalidate(); + assert!(cache.get().is_none()); + cache.store(vec![Line::from("new")]); + let lines = cache.get().unwrap(); + assert_eq!(lines.len(), 1); + let span_content: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(span_content, "new"); + } + + #[test] + fn cache_multiple_invalidations() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("data")]); + cache.invalidate(); + cache.invalidate(); + cache.invalidate(); + assert!(cache.get().is_none()); + cache.store(vec![Line::from("fresh")]); + assert!(cache.get().is_some()); + } + + #[test] + fn cache_store_empty_lines() { + let mut cache = BlockCache::default(); + cache.store(Vec::new()); + let lines = cache.get().unwrap(); + assert!(lines.is_empty()); + } + + /// Store twice without invalidating - second store overwrites first. + #[test] + fn cache_store_overwrite_without_invalidate() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("first")]); + cache.store(vec![Line::from("second"), Line::from("line2")]); + let lines = cache.get().unwrap(); + assert_eq!(lines.len(), 2); + let content: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(content, "second"); + } + + /// `get()` called twice returns consistent data. + #[test] + fn cache_get_twice_consistent() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("stable")]); + let first = cache.get().unwrap().len(); + let second = cache.get().unwrap().len(); + assert_eq!(first, second); + } + + // BlockCache + + #[test] + fn cache_store_many_lines() { + let mut cache = BlockCache::default(); + let lines: Vec> = + (0..1000).map(|i| Line::from(Span::raw(format!("line {i}")))).collect(); + cache.store(lines); + assert_eq!(cache.get().unwrap().len(), 1000); + } + + #[test] + fn cache_store_splits_into_kb_segments() { + let mut cache = BlockCache::default(); + let long = "x".repeat(800); + let lines: Vec> = (0..12).map(|_| Line::from(long.clone())).collect(); + cache.store(lines); + assert!(cache.segment_count() > 1); + assert!(cache.cached_bytes() > 0); + } + + #[test] + fn cache_invalidate_without_store() { + let mut cache = BlockCache::default(); + cache.invalidate(); + assert!(cache.get().is_none()); + } + + #[test] + fn cache_rapid_store_invalidate_cycle() { + let mut cache = BlockCache::default(); + for i in 0..50 { + cache.store(vec![Line::from(format!("v{i}"))]); + assert!(cache.get().is_some()); + cache.invalidate(); + assert!(cache.get().is_none()); + } + cache.store(vec![Line::from("final")]); + assert!(cache.get().is_some()); + } + + /// Store styled lines with multiple spans per line. + #[test] + fn cache_store_styled_lines() { + let mut cache = BlockCache::default(); + let line = Line::from(vec![ + Span::styled("bold", Style::default().fg(Color::Red)), + Span::raw(" normal "), + Span::styled("blue", Style::default().fg(Color::Blue)), + ]); + cache.store(vec![line]); + let lines = cache.get().unwrap(); + assert_eq!(lines[0].spans.len(), 3); + } + + /// Version counter after many invalidations - verify it doesn't + /// accidentally wrap to 0 (which would make stale data appear fresh). + /// With u64, 10K invalidations is nowhere near overflow. + #[test] + fn cache_version_no_false_fresh_after_many_invalidations() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("data")]); + for _ in 0..10_000 { + cache.invalidate(); + } + // Cache was invalidated 10K times without re-storing - must be stale + assert!(cache.get().is_none()); + } + + /// Invalidate, store, invalidate, store - alternating pattern. + #[test] + fn cache_alternating_invalidate_store() { + let mut cache = BlockCache::default(); + for i in 0..100 { + cache.invalidate(); + assert!(cache.get().is_none(), "stale after invalidate at iter {i}"); + cache.store(vec![Line::from(format!("v{i}"))]); + assert!(cache.get().is_some(), "fresh after store at iter {i}"); + } + } + + // BlockCache height + + #[test] + fn cache_height_default_returns_none() { + let cache = BlockCache::default(); + assert!(cache.height_at(80).is_none()); + } + + #[test] + fn cache_store_with_height_then_height_at() { + let mut cache = BlockCache::default(); + cache.store_with_height(vec![Line::from("hello")], 1, 80); + assert_eq!(cache.height_at(80), Some(1)); + assert!(cache.get().is_some()); + } + + #[test] + fn cache_height_at_wrong_width_returns_none() { + let mut cache = BlockCache::default(); + cache.store_with_height(vec![Line::from("hello")], 1, 80); + assert!(cache.height_at(120).is_none()); + } + + #[test] + fn cache_height_invalidated_returns_none() { + let mut cache = BlockCache::default(); + cache.store_with_height(vec![Line::from("hello")], 1, 80); + cache.invalidate(); + assert!(cache.height_at(80).is_none()); + } + + #[test] + fn cache_store_without_height_has_no_height() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("hello")]); + // store() without height leaves wrapped_width at 0 + assert!(cache.height_at(80).is_none()); + } + + #[test] + fn cache_store_with_height_overwrite() { + let mut cache = BlockCache::default(); + cache.store_with_height(vec![Line::from("old")], 1, 80); + cache.invalidate(); + cache.store_with_height(vec![Line::from("new long line")], 3, 120); + assert_eq!(cache.height_at(120), Some(3)); + assert!(cache.height_at(80).is_none()); + } + + // BlockCache set_height (separate from store) + + #[test] + fn cache_set_height_after_store() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("hello")]); + assert!(cache.height_at(80).is_none()); // no height yet + cache.set_height(1, 80); + assert_eq!(cache.height_at(80), Some(1)); + assert!(cache.get().is_some()); // lines still valid + } + + #[test] + fn cache_set_height_update_width() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("hello world")]); + cache.set_height(1, 80); + assert_eq!(cache.height_at(80), Some(1)); + // Re-measure at new width + cache.set_height(2, 40); + assert_eq!(cache.height_at(40), Some(2)); + assert!(cache.height_at(80).is_none()); // old width no longer valid + } + + #[test] + fn cache_set_height_invalidate_clears_height() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("data")]); + cache.set_height(3, 80); + cache.invalidate(); + assert!(cache.height_at(80).is_none()); // version mismatch + } + + #[test] + fn cache_set_height_on_invalidated_cache_returns_none() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("data")]); + cache.invalidate(); // version != 0 + cache.set_height(5, 80); + // height_at returns None because cache is stale (version != 0) + assert!(cache.height_at(80).is_none()); + } + + #[test] + fn cache_store_then_set_height_matches_store_with_height() { + let mut cache_a = BlockCache::default(); + cache_a.store(vec![Line::from("test")]); + cache_a.set_height(2, 100); + + let mut cache_b = BlockCache::default(); + cache_b.store_with_height(vec![Line::from("test")], 2, 100); + + assert_eq!(cache_a.height_at(100), cache_b.height_at(100)); + assert_eq!(cache_a.get().unwrap().len(), cache_b.get().unwrap().len()); + } + + #[test] + fn cache_measure_and_set_height_from_segments() { + let mut cache = BlockCache::default(); + let lines = vec![ + Line::from("alpha beta gamma delta epsilon"), + Line::from("zeta eta theta iota kappa lambda"), + Line::from("mu nu xi omicron pi rho sigma"), + ]; + cache.store(lines.clone()); + let measured = cache.measure_and_set_height(16).expect("expected measured height"); + let expected = ratatui::widgets::Paragraph::new(ratatui::text::Text::from(lines)) + .wrap(ratatui::widgets::Wrap { trim: false }) + .line_count(16); + assert_eq!(measured, expected); + assert_eq!(cache.height_at(16), Some(expected)); + } + + #[test] + fn cache_get_updates_last_access_tick() { + let mut cache = BlockCache::default(); + cache.store(vec![Line::from("tick")]); + let before = cache.last_access_tick(); + let _ = cache.get(); + let after = cache.last_access_tick(); + assert!(after > before); + } + + // App tool_call_index + + fn make_test_app() -> App { + App::test_default() + } + + fn assistant_text_block(text: &str) -> MessageBlock { + MessageBlock::Text(TextBlock::from_complete(text)) + } + + fn user_text_message(text: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::User, + blocks: vec![assistant_text_block(text)], + usage: None, + } + } + + fn assistant_tool_message(id: &str, status: model::ToolCallStatus) -> ChatMessage { + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(ToolCallInfo { + id: id.to_owned(), + title: format!("tool {id}"), + sdk_tool_name: "Read".to_owned(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status, + content: Vec::new(), + hidden: false, + terminal_id: None, + terminal_command: None, + terminal_output: Some("x".repeat(1024)), + terminal_output_len: 1024, + terminal_bytes_seen: 1024, + terminal_snapshot_mode: TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + }))], + usage: None, + } + } + + fn assistant_bash_tool_message( + id: &str, + status: model::ToolCallStatus, + terminal_id: &str, + ) -> ChatMessage { + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(ToolCallInfo { + id: id.to_owned(), + title: format!("tool {id}"), + sdk_tool_name: "Bash".to_owned(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status, + content: Vec::new(), + hidden: false, + terminal_id: Some(terminal_id.to_owned()), + terminal_command: Some("echo hi".to_owned()), + terminal_output: Some("x".repeat(1024)), + terminal_output_len: 1024, + terminal_bytes_seen: 1024, + terminal_snapshot_mode: TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + }))], + usage: None, + } + } + + fn assistant_tool_message_with_pending_permission(id: &str) -> ChatMessage { + let (tx, _rx) = tokio::sync::oneshot::channel(); + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(ToolCallInfo { + id: id.to_owned(), + title: format!("tool {id}"), + sdk_tool_name: "Read".to_owned(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status: model::ToolCallStatus::Completed, + content: Vec::new(), + hidden: false, + terminal_id: None, + terminal_command: None, + terminal_output: Some("x".repeat(1024)), + terminal_output_len: 1024, + terminal_bytes_seen: 1024, + terminal_snapshot_mode: TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: Some(InlinePermission { + options: vec![model::PermissionOption::new( + "allow-once", + "Allow once", + model::PermissionOptionKind::AllowOnce, + )], + response_tx: tx, + selected_index: 0, + focused: false, + }), + pending_question: None, + }))], + usage: None, + } + } + + #[test] + fn enforce_render_cache_budget_evicts_lru_block() { + let mut app = make_test_app(); + app.messages = vec![ + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("a")], + usage: None, + }, + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("b")], + usage: None, + }, + ]; + + let bytes_a = if let MessageBlock::Text(block) = &mut app.messages[0].blocks[0] { + block.cache.store(vec![Line::from("x".repeat(2200))]); + block.cache.cached_bytes() + } else { + 0 + }; + let bytes_b = if let MessageBlock::Text(block) = &mut app.messages[1].blocks[0] { + block.cache.store(vec![Line::from("y".repeat(2200))]); + let _ = block.cache.get(); + block.cache.cached_bytes() + } else { + 0 + }; + + app.render_cache_budget.max_bytes = bytes_b; + let stats = app.enforce_render_cache_budget(); + assert!(stats.evicted_blocks >= 1); + assert!(stats.evicted_bytes >= bytes_a); + assert!(stats.total_after_bytes <= app.render_cache_budget.max_bytes); + assert_eq!(stats.protected_bytes, 0); + + if let MessageBlock::Text(block) = &app.messages[0].blocks[0] { + assert_eq!(block.cache.cached_bytes(), 0); + } else { + panic!("expected text block"); + } + if let MessageBlock::Text(block) = &app.messages[1].blocks[0] { + assert_eq!(block.cache.cached_bytes(), bytes_b); + } else { + panic!("expected text block"); + } + } + + #[test] + fn enforce_render_cache_budget_protects_streaming_tail_message() { + let mut app = make_test_app(); + app.status = AppStatus::Thinking; + app.messages = vec![ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("streaming tail")], + usage: None, + }]; + + let before = if let MessageBlock::Text(block) = &mut app.messages[0].blocks[0] { + block.cache.store(vec![Line::from("z".repeat(4096))]); + block.cache.cached_bytes() + } else { + 0 + }; + app.render_cache_budget.max_bytes = 64; + let stats = app.enforce_render_cache_budget(); + assert_eq!(stats.evicted_blocks, 0); + assert_eq!(stats.evicted_bytes, 0); + assert_eq!(stats.protected_bytes, before); + + if let MessageBlock::Text(block) = &app.messages[0].blocks[0] { + assert_eq!(block.cache.cached_bytes(), before); + } else { + panic!("expected text block"); + } + } + + #[test] + fn enforce_render_cache_budget_excludes_protected_from_budget() { + let mut app = make_test_app(); + app.status = AppStatus::Running; + app.messages = vec![ + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("old message")], + usage: None, + }, + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("streaming tail")], + usage: None, + }, + ]; + + let bytes_a = if let MessageBlock::Text(block) = &mut app.messages[0].blocks[0] { + block.cache.store(vec![Line::from("x".repeat(2200))]); + block.cache.cached_bytes() + } else { + 0 + }; + let bytes_b = if let MessageBlock::Text(block) = &mut app.messages[1].blocks[0] { + block.cache.store(vec![Line::from("y".repeat(5000))]); + block.cache.cached_bytes() + } else { + 0 + }; + + // Budget fits old message alone but not old + tail combined. + app.render_cache_budget.max_bytes = bytes_a + 100; + assert!(bytes_a + bytes_b > app.render_cache_budget.max_bytes); + + let stats = app.enforce_render_cache_budget(); + + // Protected bytes should be the streaming tail. + assert_eq!(stats.protected_bytes, bytes_b); + // No eviction: budgeted bytes (bytes_a) are under max_bytes. + assert_eq!(stats.evicted_blocks, 0); + assert_eq!(stats.evicted_bytes, 0); + // Old message cache intact. + if let MessageBlock::Text(block) = &app.messages[0].blocks[0] { + assert_eq!(block.cache.cached_bytes(), bytes_a); + } else { + panic!("expected text block"); + } + } + + #[test] + fn enforce_render_cache_budget_protects_active_streaming_owner_not_physical_tail() { + let mut app = make_test_app(); + app.status = AppStatus::Running; + app.messages = vec![ + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("old message")], + usage: None, + }, + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("active streaming owner")], + usage: None, + }, + ChatMessage { + role: MessageRole::System(Some(SystemSeverity::Info)), + blocks: vec![assistant_text_block("late trailing system row")], + usage: None, + }, + ]; + app.bind_active_turn_assistant(1); + + if let MessageBlock::Text(block) = &mut app.messages[0].blocks[0] { + block.cache.store(vec![Line::from("x".repeat(2000))]); + } + let protected_bytes = if let MessageBlock::Text(block) = &mut app.messages[1].blocks[0] { + block.cache.store(vec![Line::from("y".repeat(4000))]); + block.cache.cached_bytes() + } else { + 0 + }; + if let MessageBlock::Text(block) = &mut app.messages[2].blocks[0] { + block.cache.store(vec![Line::from("z".repeat(5000))]); + } + + app.render_cache_budget.max_bytes = 64; + let stats = app.enforce_render_cache_budget(); + + assert_eq!(stats.protected_bytes, protected_bytes); + } + + #[test] + fn enforce_render_cache_budget_evicts_when_budgeted_over_limit() { + let mut app = make_test_app(); + app.status = AppStatus::Running; + app.messages = vec![ + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("old-a")], + usage: None, + }, + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("old-b")], + usage: None, + }, + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("streaming")], + usage: None, + }, + ]; + + // Populate caches: messages 0 and 1 evictable, message 2 protected. + if let MessageBlock::Text(block) = &mut app.messages[0].blocks[0] { + block.cache.store(vec![Line::from("x".repeat(3000))]); + } + let bytes_b = if let MessageBlock::Text(block) = &mut app.messages[1].blocks[0] { + block.cache.store(vec![Line::from("y".repeat(3000))]); + let _ = block.cache.get(); // touch to make more recently accessed + block.cache.cached_bytes() + } else { + 0 + }; + let bytes_c = if let MessageBlock::Text(block) = &mut app.messages[2].blocks[0] { + block.cache.store(vec![Line::from("z".repeat(5000))]); + block.cache.cached_bytes() + } else { + 0 + }; + + // Budget fits message B but not A+B (excludes C as protected). + app.render_cache_budget.max_bytes = bytes_b + 100; + + let stats = app.enforce_render_cache_budget(); + + assert_eq!(stats.protected_bytes, bytes_c); + assert!(stats.evicted_blocks >= 1); // message A evicted (older access) + // Message B should survive (more recent access). + if let MessageBlock::Text(block) = &app.messages[1].blocks[0] { + assert_eq!(block.cache.cached_bytes(), bytes_b); + } else { + panic!("expected text block"); + } + } + + #[test] + fn enforce_render_cache_budget_protected_bytes_zero_when_not_streaming() { + let mut app = make_test_app(); + app.status = AppStatus::Ready; + app.messages = vec![ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("done")], + usage: None, + }]; + + if let MessageBlock::Text(block) = &mut app.messages[0].blocks[0] { + block.cache.store(vec![Line::from("x".repeat(2000))]); + } + app.render_cache_budget.max_bytes = usize::MAX; + + let stats = app.enforce_render_cache_budget(); + assert_eq!(stats.protected_bytes, 0); + } + + #[test] + fn enforce_history_retention_noop_under_budget() { + let mut app = make_test_app(); + app.messages = vec![ + ChatMessage::welcome("model", "/cwd"), + user_text_message("small message"), + user_text_message("another message"), + ]; + app.history_retention.max_bytes = usize::MAX / 4; + + let stats = app.enforce_history_retention(); + assert_eq!(stats.dropped_messages, 0); + assert_eq!(stats.total_dropped_messages, 0); + assert!(!app.messages.iter().any(App::is_history_hidden_marker_message)); + } + + #[test] + fn enforce_history_retention_drops_oldest_and_adds_marker() { + let mut app = make_test_app(); + app.messages = vec![ + ChatMessage::welcome("model", "/cwd"), + user_text_message("first old message"), + user_text_message("second old message"), + user_text_message("third old message"), + ]; + app.history_retention.max_bytes = 1; + + let stats = app.enforce_history_retention(); + assert_eq!(stats.dropped_messages, 3); + assert!(matches!(app.messages[0].role, MessageRole::Welcome)); + assert!(app.messages.iter().any(App::is_history_hidden_marker_message)); + assert_eq!(app.messages.len(), 2); + } + + #[test] + fn enforce_history_retention_preserves_in_progress_tool_message() { + let mut app = make_test_app(); + app.messages = vec![ + ChatMessage::welcome("model", "/cwd"), + user_text_message("droppable"), + assistant_tool_message("tool-keep", model::ToolCallStatus::InProgress), + ]; + app.history_retention.max_bytes = 1; + + let stats = app.enforce_history_retention(); + assert_eq!(stats.dropped_messages, 1); + assert!(app.messages.iter().any(|msg| { + msg.blocks.iter().any(|block| { + matches!( + block, + MessageBlock::ToolCall(tc) if tc.id == "tool-keep" + && matches!(tc.status, model::ToolCallStatus::InProgress) + ) + }) + })); + } + + #[test] + fn enforce_history_retention_preserves_pending_tool_message() { + let mut app = make_test_app(); + app.messages = vec![ + ChatMessage::welcome("model", "/cwd"), + user_text_message("droppable"), + assistant_tool_message("tool-pending", model::ToolCallStatus::Pending), + ]; + app.history_retention.max_bytes = 1; + + let stats = app.enforce_history_retention(); + assert_eq!(stats.dropped_messages, 1); + assert!(app.messages.iter().any(|msg| { + msg.blocks + .iter() + .any(|block| matches!(block, MessageBlock::ToolCall(tc) if tc.id == "tool-pending")) + })); + } + + #[test] + fn enforce_history_retention_preserves_permission_tool_message() { + let mut app = make_test_app(); + app.messages = vec![ + ChatMessage::welcome("model", "/cwd"), + user_text_message("droppable"), + assistant_tool_message_with_pending_permission("tool-perm"), + ]; + app.history_retention.max_bytes = 1; + + let stats = app.enforce_history_retention(); + assert_eq!(stats.dropped_messages, 1); + assert!(app.messages.iter().any(|msg| { + msg.blocks + .iter() + .any(|block| matches!(block, MessageBlock::ToolCall(tc) if tc.id == "tool-perm")) + })); + } + + #[test] + fn enforce_history_retention_rebuilds_tool_index_after_prune() { + let mut app = make_test_app(); + app.messages = vec![ + ChatMessage::welcome("model", "/cwd"), + user_text_message("drop this"), + assistant_bash_tool_message("tool-idx", model::ToolCallStatus::InProgress, "term-1"), + ]; + app.index_tool_call("tool-idx".to_owned(), 99, 99); + app.sync_terminal_tool_call("stale-term".to_owned(), 99, 99); + app.history_retention.max_bytes = 1; + + let _ = app.enforce_history_retention(); + assert_eq!(app.lookup_tool_call("tool-idx"), Some((2, 0))); + assert_eq!(app.terminal_tool_calls.len(), 1); + assert_eq!(app.terminal_tool_call_membership.len(), 1); + assert_eq!(app.terminal_tool_calls[0].terminal_id, "term-1"); + assert_eq!(app.terminal_tool_calls[0].msg_idx, 2); + assert_eq!(app.terminal_tool_calls[0].block_idx, 0); + } + + #[test] + fn enforce_history_retention_preserves_active_turn_assistant_message() { + let mut app = make_test_app(); + app.status = AppStatus::Thinking; + app.messages = vec![ + ChatMessage::welcome("model", "/cwd"), + user_text_message("drop this"), + ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }, + ]; + app.bind_active_turn_assistant(2); + app.history_retention.max_bytes = 1; + + let stats = app.enforce_history_retention(); + + assert_eq!(stats.dropped_messages, 1); + assert_eq!(app.active_turn_assistant_idx(), Some(2)); + assert!(matches!(app.messages[2].role, MessageRole::Assistant)); + } + + #[test] + fn enforce_history_retention_remaps_active_turn_assistant_after_prune() { + let mut app = make_test_app(); + app.status = AppStatus::Thinking; + app.messages = vec![ + user_text_message("drop this"), + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![assistant_text_block("streaming reply")], + usage: None, + }, + ]; + app.bind_active_turn_assistant(1); + app.history_retention.max_bytes = App::measure_message_bytes(&app.messages[1]); + + let stats = app.enforce_history_retention(); + + assert_eq!(stats.dropped_messages, 1); + assert_eq!(app.active_turn_assistant_idx(), Some(1)); + assert!(App::is_history_hidden_marker_message(&app.messages[0])); + assert!(matches!(app.messages[1].role, MessageRole::Assistant)); + } + + #[test] + fn enforce_history_retention_keeps_single_marker_on_repeat() { + let mut app = make_test_app(); + app.messages = vec![ChatMessage::welcome("model", "/cwd"), user_text_message("drop me")]; + app.history_retention.max_bytes = 1; + + let first = app.enforce_history_retention(); + let second = app.enforce_history_retention(); + let marker_count = + app.messages.iter().filter(|msg| App::is_history_hidden_marker_message(msg)).count(); + + assert_eq!(first.dropped_messages, 1); + assert_eq!(second.dropped_messages, 0); + assert_eq!(marker_count, 1); + } + + #[allow(clippy::cast_precision_loss)] + #[test] + fn enforce_history_retention_preserves_manual_scroll_anchor_across_drop_and_marker_insert() { + let mut app = make_test_app(); + app.messages = vec![ + ChatMessage::welcome("model", "/cwd"), + user_text_message("drop me first"), + user_text_message("keep this anchored"), + user_text_message("tail"), + ]; + let _ = app.viewport.on_frame(40, 12); + app.viewport.sync_message_count(app.messages.len()); + for idx in 0..app.messages.len() { + app.viewport.set_message_height(idx, 4); + } + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + + app.viewport.auto_scroll = false; + app.viewport.scroll_offset = 9; + app.viewport.scroll_target = 9; + app.viewport.scroll_pos = 9.0; + app.history_retention.max_bytes = app + .measure_history_bytes() + .saturating_sub(App::measure_message_bytes(&app.messages[1])); + + let _ = app.enforce_history_retention(); + + assert!(app.messages.iter().any(App::is_history_hidden_marker_message)); + assert_eq!(app.viewport.scroll_anchor_to_restore(), Some((2, 1))); + } + + #[test] + fn lookup_missing_returns_none() { + let app = make_test_app(); + assert!(app.lookup_tool_call("nonexistent").is_none()); + } + + #[test] + fn index_and_lookup() { + let mut app = make_test_app(); + app.index_tool_call("tc-123".into(), 2, 5); + assert_eq!(app.lookup_tool_call("tc-123"), Some((2, 5))); + } + + // App tool_call_index + + /// Index same ID twice - second write overwrites first. + #[test] + fn index_overwrite_existing() { + let mut app = make_test_app(); + app.index_tool_call("tc-1".into(), 0, 0); + app.index_tool_call("tc-1".into(), 5, 10); + assert_eq!(app.lookup_tool_call("tc-1"), Some((5, 10))); + } + + /// Empty string as tool call ID. + #[test] + fn index_empty_string_id() { + let mut app = make_test_app(); + app.index_tool_call(String::new(), 1, 2); + assert_eq!(app.lookup_tool_call(""), Some((1, 2))); + } + + /// Stress: 1000 tool calls indexed and looked up. + #[test] + fn index_stress_1000_entries() { + let mut app = make_test_app(); + for i in 0..1000 { + app.index_tool_call(format!("tc-{i}"), i, i * 2); + } + // Spot check first, middle, last + assert_eq!(app.lookup_tool_call("tc-0"), Some((0, 0))); + assert_eq!(app.lookup_tool_call("tc-500"), Some((500, 1000))); + assert_eq!(app.lookup_tool_call("tc-999"), Some((999, 1998))); + // Non-existent still returns None + assert!(app.lookup_tool_call("tc-1000").is_none()); + } + + /// Unicode in tool call ID. + #[test] + fn index_unicode_id() { + let mut app = make_test_app(); + app.index_tool_call("\u{1F600}-tool".into(), 3, 7); + assert_eq!(app.lookup_tool_call("\u{1F600}-tool"), Some((3, 7))); + } + + // active_task_ids + + #[test] + fn active_task_insert_remove() { + let mut app = make_test_app(); + app.insert_active_task("task-1".into()); + assert!(app.active_task_ids.contains("task-1")); + app.remove_active_task("task-1"); + assert!(!app.active_task_ids.contains("task-1")); + } + + #[test] + fn remove_nonexistent_task_is_noop() { + let mut app = make_test_app(); + app.remove_active_task("does-not-exist"); + assert!(app.active_task_ids.is_empty()); + } + + // active_task_ids + + /// Insert same ID twice - set deduplicates; one remove clears it. + #[test] + fn active_task_insert_duplicate() { + let mut app = make_test_app(); + app.insert_active_task("task-1".into()); + app.insert_active_task("task-1".into()); + assert_eq!(app.active_task_ids.len(), 1); + app.remove_active_task("task-1"); + assert!(app.active_task_ids.is_empty()); + } + + /// Insert many tasks, remove in different order. + #[test] + fn active_task_insert_many_remove_out_of_order() { + let mut app = make_test_app(); + for i in 0..100 { + app.insert_active_task(format!("task-{i}")); + } + assert_eq!(app.active_task_ids.len(), 100); + // Remove in reverse order + for i in (0..100).rev() { + app.remove_active_task(&format!("task-{i}")); + } + assert!(app.active_task_ids.is_empty()); + } + + /// Mixed insert/remove interleaving. + #[test] + fn active_task_interleaved_insert_remove() { + let mut app = make_test_app(); + app.insert_active_task("a".into()); + app.insert_active_task("b".into()); + app.remove_active_task("a"); + app.insert_active_task("c".into()); + assert!(!app.active_task_ids.contains("a")); + assert!(app.active_task_ids.contains("b")); + assert!(app.active_task_ids.contains("c")); + assert_eq!(app.active_task_ids.len(), 2); + } + + /// Remove from empty set multiple times - no panic. + #[test] + fn active_task_remove_from_empty_repeatedly() { + let mut app = make_test_app(); + for i in 0..100 { + app.remove_active_task(&format!("ghost-{i}")); + } + assert!(app.active_task_ids.is_empty()); + } + + /// `clear_tool_scope_tracking` must also clear `active_task_ids`. + /// Regression test: before the fix, a leaked task ID from a cancelled turn + /// caused main-agent tools on the next turn to be misclassified as Subagent scope. + #[test] + fn clear_tool_scope_tracking_also_clears_active_task_ids() { + let mut app = make_test_app(); + app.insert_active_task("task-leaked".into()); + assert!(!app.active_task_ids.is_empty()); + app.clear_tool_scope_tracking(); + assert!(app.active_task_ids.is_empty(), "active_task_ids must be cleared at turn end"); + assert!(app.active_subagent_tool_ids.is_empty()); + assert!(app.subagent_idle_since.is_none()); + } + + #[test] + fn finalize_in_progress_tool_calls_detaches_execute_terminal_refs() { + let mut app = make_test_app(); + app.messages.push(assistant_bash_tool_message( + "bash-1", + model::ToolCallStatus::InProgress, + "term-1", + )); + app.index_tool_call("bash-1".to_owned(), 0, 0); + app.sync_terminal_tool_call("term-1".to_owned(), 0, 0); + + let changed = app.finalize_in_progress_tool_calls(model::ToolCallStatus::Completed); + + assert_eq!(changed, 1); + assert!(app.terminal_tool_calls.is_empty()); + assert!(app.terminal_tool_call_membership.is_empty()); + let MessageBlock::ToolCall(tc) = &app.messages[0].blocks[0] else { + panic!("expected tool call"); + }; + assert_eq!(tc.status, model::ToolCallStatus::Completed); + assert_eq!(tc.terminal_id, None); + } + + #[test] + fn insert_message_tracked_nontail_rebuilds_tool_indices_and_invalidates_suffix() { + let mut app = make_test_app(); + app.messages.push(user_text_message("before")); + app.messages.push(assistant_tool_message("tool-1", model::ToolCallStatus::Completed)); + app.messages.push(user_text_message("after")); + app.index_tool_call("tool-1".to_owned(), 1, 0); + + let _ = app.viewport.on_frame(80, 24); + app.viewport.sync_message_count(3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + + app.insert_message_tracked(1, user_text_message("inserted")); + app.viewport.sync_message_count(app.messages.len()); + + assert_eq!(app.lookup_tool_call("tool-1"), Some((2, 0))); + assert_eq!(app.viewport.oldest_stale_index(), Some(1)); + assert_eq!(app.viewport.prefix_dirty_from(), Some(1)); + } + + #[test] + fn remove_message_tracked_nontail_rebuilds_tool_indices_and_invalidates_suffix() { + let mut app = make_test_app(); + app.messages.push(user_text_message("before")); + app.messages.push(assistant_tool_message("tool-1", model::ToolCallStatus::Completed)); + app.messages.push(user_text_message("after")); + app.index_tool_call("tool-1".to_owned(), 1, 0); + + let _ = app.viewport.on_frame(80, 24); + app.viewport.sync_message_count(3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + + let removed = app.remove_message_tracked(0); + app.viewport.sync_message_count(app.messages.len()); + + assert!(removed.is_some()); + assert_eq!(app.lookup_tool_call("tool-1"), Some((0, 0))); + assert_eq!(app.viewport.oldest_stale_index(), Some(0)); + assert_eq!(app.viewport.prefix_dirty_from(), Some(0)); + } + + #[test] + fn remove_message_tracked_tail_removes_orphaned_tool_indices() { + let mut app = make_test_app(); + app.messages.push(user_text_message("before")); + app.messages.push(assistant_tool_message("tool-1", model::ToolCallStatus::Completed)); + app.index_tool_call("tool-1".to_owned(), 1, 0); + + let removed = app.remove_message_tracked(1); + + assert!(removed.is_some()); + assert!(app.lookup_tool_call("tool-1").is_none()); + } + + #[test] + fn remove_message_tracked_prunes_tool_scope_entries() { + let mut app = make_test_app(); + app.messages.push(assistant_tool_message("tool-1", model::ToolCallStatus::Completed)); + app.index_tool_call("tool-1".to_owned(), 0, 0); + app.register_tool_call_scope("tool-1".to_owned(), ToolCallScope::Subagent); + + let removed = app.remove_message_tracked(0); + + assert!(removed.is_some()); + assert_eq!(app.tool_call_scope("tool-1"), None); + } + + #[test] + fn clear_messages_tracked_clears_tool_and_terminal_tracking() { + let mut app = make_test_app(); + app.messages.push(assistant_bash_tool_message( + "bash-1", + model::ToolCallStatus::InProgress, + "term-1", + )); + app.index_tool_call("bash-1".to_owned(), 0, 0); + app.sync_terminal_tool_call("term-1".to_owned(), 0, 0); + app.pending_interaction_ids.push("bash-1".into()); + + app.clear_messages_tracked(); + + assert!(app.messages.is_empty()); + assert!(app.tool_call_index.is_empty()); + assert!(app.terminal_tool_calls.is_empty()); + assert!(app.terminal_tool_call_membership.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); + } + + #[test] + fn rebuild_tool_indices_skips_completed_terminal_refs() { + let mut app = make_test_app(); + app.messages.push(assistant_bash_tool_message( + "bash-1", + model::ToolCallStatus::Completed, + "term-1", + )); + app.index_tool_call("bash-1".to_owned(), 0, 0); + app.sync_terminal_tool_call("term-1".to_owned(), 0, 0); + + app.rebuild_tool_indices_and_terminal_refs(); + + assert!(app.terminal_tool_calls.is_empty()); + assert!(app.terminal_tool_call_membership.is_empty()); + } + + #[test] + fn finalize_in_progress_tool_calls_invalidates_all_changed_messages() { + let mut app = make_test_app(); + app.messages.push(assistant_tool_message("tool-1", model::ToolCallStatus::InProgress)); + app.messages.push(user_text_message("gap")); + app.messages.push(assistant_tool_message("tool-2", model::ToolCallStatus::InProgress)); + + let _ = app.viewport.on_frame(80, 24); + app.viewport.sync_message_count(3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + + let changed = app.finalize_in_progress_tool_calls(model::ToolCallStatus::Completed); + + assert_eq!(changed, 2); + assert!(!app.viewport.message_height_is_current(0)); + assert!(app.viewport.message_height_is_current(1)); + assert!(!app.viewport.message_height_is_current(2)); + assert_eq!(app.viewport.oldest_stale_index(), Some(0)); + } + + // IncrementalMarkdown + + /// Simple render function for tests: wraps each line in a `Line`. + fn test_render(src: &str) -> Vec> { + src.lines().map(|l| Line::from(l.to_owned())).collect() + } + + fn test_render_key() -> super::messages::MarkdownRenderKey { + super::messages::MarkdownRenderKey { width: 80, bg: None, preserve_newlines: false } + } + + #[test] + fn incr_default_empty() { + let incr = IncrementalMarkdown::default(); + assert!(incr.full_text().is_empty()); + } + + #[test] + fn incr_from_complete() { + let incr = IncrementalMarkdown::from_complete("hello world"); + assert_eq!(incr.full_text(), "hello world"); + } + + #[test] + fn incr_append_single_chunk() { + let mut incr = IncrementalMarkdown::default(); + incr.append("hello"); + assert_eq!(incr.full_text(), "hello"); + } + + #[test] + fn incr_append_accumulates_chunks() { + let mut incr = IncrementalMarkdown::default(); + incr.append("line1"); + incr.append("\nline2"); + incr.append("\nline3"); + assert_eq!(incr.full_text(), "line1\nline2\nline3"); + } + + #[test] + fn incr_append_preserves_paragraph_delimiters() { + let mut incr = IncrementalMarkdown::default(); + incr.append("para1\n\npara2"); + assert_eq!(incr.full_text(), "para1\n\npara2"); + } + + #[test] + fn incr_full_text_reconstruction() { + let mut incr = IncrementalMarkdown::default(); + incr.append("p1\n\np2\n\np3"); + assert_eq!(incr.full_text(), "p1\n\np2\n\np3"); + } + + #[test] + fn incr_lines_renders_all() { + let mut incr = IncrementalMarkdown::default(); + incr.append("line1\n\nline2\n\nline3"); + let lines = incr.lines(test_render_key(), &test_render); + // test_render maps each source line to one output line + assert_eq!(lines.len(), 5); + } + + #[test] + fn incr_ensure_rendered_preserves_text() { + let mut incr = IncrementalMarkdown::default(); + incr.append("p1\n\np2\n\ntail"); + incr.ensure_rendered(test_render_key(), &test_render); + assert_eq!(incr.full_text(), "p1\n\np2\n\ntail"); + } + + #[test] + fn incr_invalidate_renders_preserves_text() { + let mut incr = IncrementalMarkdown::default(); + incr.append("p1\n\np2\n\ntail"); + incr.invalidate_renders(); + assert_eq!(incr.full_text(), "p1\n\np2\n\ntail"); + } + + #[test] + fn incr_reuses_rendered_prefix_chunks() { + use std::cell::Cell; + + let calls = Cell::new(0usize); + let render = |src: &str| -> Vec> { + calls.set(calls.get() + 1); + test_render(src) + }; + + let mut incr = IncrementalMarkdown::default(); + incr.append("p1\n\np2"); + let _ = incr.lines(test_render_key(), &render); + assert_eq!(calls.get(), 2); + + incr.append(" tail"); + let _ = incr.lines(test_render_key(), &render); + assert_eq!(calls.get(), 3); + } + + #[test] + fn incr_does_not_split_inside_fenced_code_blocks() { + let calls = std::cell::Cell::new(0usize); + let render = |src: &str| -> Vec> { + calls.set(calls.get() + 1); + test_render(src) + }; + + let mut incr = IncrementalMarkdown::default(); + incr.append("```rust\nfn main() {\n\nprintln!(\"hi\");\n}\n```\n\nafter"); + let _ = incr.lines(test_render_key(), &render); + + assert_eq!(calls.get(), 2); + } + + #[test] + fn incr_streaming_simulation() { + // Simulate a realistic streaming scenario + let mut incr = IncrementalMarkdown::default(); + let chunks = ["Here is ", "some text.\n", "\nNext para", "graph here.\n\n", "Final."]; + for chunk in chunks { + incr.append(chunk); + } + assert_eq!(incr.full_text(), "Here is some text.\n\nNext paragraph here.\n\nFinal."); + } + + // ChatViewport + + #[test] + fn viewport_new_defaults() { + let vp = ChatViewport::new(); + assert_eq!(vp.scroll_offset, 0); + assert_eq!(vp.scroll_target, 0); + assert!(vp.auto_scroll); + assert_eq!(vp.width, 0); + assert!(vp.message_heights.is_empty()); + assert!(vp.oldest_stale_index().is_none()); + assert!(!vp.resize_remeasure_active()); + assert!(vp.height_prefix_sums.is_empty()); + } + + #[test] + fn viewport_on_frame_sets_width() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + assert_eq!(vp.width, 80); + assert_eq!(vp.height, 24); + } + + #[test] + fn viewport_on_frame_resize_invalidates() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.set_message_height(0, 10); + vp.set_message_height(1, 20); + vp.rebuild_prefix_sums(); + + // Resize: old heights are kept as approximations, + // but width markers are invalidated so re-measurement happens. + let _ = vp.on_frame(120, 24); + assert_eq!(vp.message_height(0), 10); // kept, not zeroed + assert_eq!(vp.message_height(1), 20); // kept, not zeroed + assert_eq!(vp.message_heights_width, 0); // forces re-measure + assert_eq!(vp.prefix_sums_width, 0); // forces rebuild + } + + #[test] + fn viewport_on_frame_same_width_no_invalidation() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.set_message_height(0, 10); + let _ = vp.on_frame(80, 24); // same width + assert_eq!(vp.message_height(0), 10); // not zeroed + } + + #[test] + fn viewport_on_frame_height_change_preserves_message_measurements() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(2); + vp.set_message_height(0, 10); + vp.set_message_height(1, 20); + vp.mark_heights_valid(); + vp.rebuild_prefix_sums(); + + let change = vp.on_frame(80, 12); + + assert!(!change.width_changed); + assert!(change.height_changed); + assert_eq!(vp.height, 12); + assert_eq!(vp.message_heights_width, 80); + assert_eq!(vp.prefix_sums_width, 80); + assert!(!vp.resize_remeasure_active()); + assert!(vp.message_height_is_current(0)); + assert!(vp.message_height_is_current(1)); + } + + #[test] + fn viewport_message_height_set_and_get() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.set_message_height(0, 5); + vp.set_message_height(1, 10); + assert_eq!(vp.message_height(0), 5); + assert_eq!(vp.message_height(1), 10); + assert_eq!(vp.message_height(2), 0); // out of bounds + } + + #[test] + fn viewport_message_height_grows_vec() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.set_message_height(5, 42); + assert_eq!(vp.message_heights.len(), 6); + assert_eq!(vp.message_height(5), 42); + assert_eq!(vp.message_height(3), 0); // gap filled with 0 + } + + #[test] + fn viewport_invalidate_message_tracks_oldest_index() { + let mut vp = ChatViewport::new(); + vp.sync_message_count(8); + vp.mark_heights_valid(); + vp.invalidate_message(5); + vp.invalidate_message(2); + vp.invalidate_message(7); + assert_eq!(vp.oldest_stale_index(), Some(2)); + } + + #[test] + fn viewport_mark_heights_valid_clears_dirty_index() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(2); + vp.mark_heights_valid(); + vp.invalidate_message(1); + assert_eq!(vp.oldest_stale_index(), Some(1)); + vp.mark_heights_valid(); + assert!(vp.oldest_stale_index().is_none()); + } + + #[test] + fn viewport_resize_remeasure_tracks_partial_exactness() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(3); + vp.set_message_height(0, 4); + vp.set_message_height(1, 5); + vp.set_message_height(2, 6); + vp.mark_heights_valid(); + + let _ = vp.on_frame(120, 24); + assert!(vp.resize_remeasure_active()); + assert!(!vp.message_height_is_current(0)); + + vp.mark_message_height_measured(1); + assert!(vp.message_height_is_current(1)); + assert!(!vp.message_height_is_current(0)); + + vp.mark_heights_valid(); + assert_eq!(vp.message_heights_width, 120); + assert!(vp.message_height_is_current(0)); + assert!(!vp.resize_remeasure_active()); + } + + #[test] + fn viewport_resize_remeasure_expands_outward_from_anchor() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(6); + vp.mark_heights_valid(); + + let _ = vp.on_frame(100, 24); + vp.ensure_resize_remeasure_anchor(2, 3, 6); + + assert_eq!(vp.next_resize_remeasure_index(6), Some(1)); + assert_eq!(vp.next_resize_remeasure_index(6), Some(0)); + assert_eq!(vp.next_resize_remeasure_index(6), Some(4)); + assert_eq!(vp.next_resize_remeasure_index(6), Some(5)); + assert_eq!(vp.next_resize_remeasure_index(6), None); + assert!(!vp.resize_remeasure_active()); + } + + #[allow(clippy::cast_precision_loss)] + #[test] + fn viewport_restore_resize_anchor_keeps_same_message_visible() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(4); + for idx in 0..4 { + vp.set_message_height(idx, 5); + } + vp.mark_heights_valid(); + vp.rebuild_prefix_sums(); + + vp.auto_scroll = false; + vp.scroll_offset = 7; + vp.scroll_target = 7; + vp.scroll_pos = 7.0; + + let _ = vp.on_frame(40, 24); + let (anchor_idx, anchor_offset) = + vp.resize_scroll_anchor().expect("resize should snapshot a scroll anchor"); + assert_eq!((anchor_idx, anchor_offset), (1, 2)); + + vp.set_message_height(0, 12); + vp.set_message_height(1, 8); + vp.set_message_height(2, 6); + vp.set_message_height(3, 6); + vp.prefix_sums_width = 0; + vp.rebuild_prefix_sums(); + vp.restore_scroll_anchor(anchor_idx, anchor_offset); + + assert_eq!(vp.scroll_offset, 14); + assert_eq!(vp.find_first_visible(vp.scroll_offset), 1); + } + + #[allow(clippy::cast_precision_loss)] + #[test] + fn viewport_preserves_resize_anchor_when_followup_remeasure_replaces_plan() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(4); + for idx in 0..4 { + vp.set_message_height(idx, 5); + } + vp.mark_heights_valid(); + vp.rebuild_prefix_sums(); + + vp.auto_scroll = false; + vp.scroll_offset = 7; + vp.scroll_target = 7; + vp.scroll_pos = 7.0; + + let _ = vp.on_frame(40, 24); + let resize_anchor = vp.resize_scroll_anchor().expect("resize should preserve an anchor"); + assert_eq!(resize_anchor, (1, 2)); + assert_eq!(vp.remeasure_reason(), Some(LayoutRemeasureReason::Resize)); + + vp.invalidate_messages_from(0); + + assert_eq!(vp.remeasure_reason(), Some(LayoutRemeasureReason::MessagesFrom)); + assert_eq!(vp.resize_scroll_anchor(), Some(resize_anchor)); + assert_eq!(vp.scroll_anchor_to_restore(), Some(resize_anchor)); + } + + #[allow(clippy::cast_precision_loss)] + #[test] + fn viewport_delays_anchor_restore_until_prefix_above_is_exact() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(4); + for idx in 0..4 { + vp.set_message_height(idx, 5); + } + vp.mark_heights_valid(); + vp.rebuild_prefix_sums(); + + vp.auto_scroll = false; + vp.scroll_offset = 12; + vp.scroll_target = 12; + vp.scroll_pos = 12.0; + + let _ = vp.on_frame(40, 24); + let anchor = vp.resize_scroll_anchor().expect("resize should preserve an anchor"); + assert_eq!(anchor, (2, 2)); + assert_eq!(vp.scroll_anchor_to_restore(), Some(anchor)); + assert_eq!(vp.ready_scroll_anchor_to_restore(), None); + + vp.set_message_height(2, 9); + vp.mark_message_height_measured(2); + vp.rebuild_prefix_sums(); + assert_eq!(vp.ready_scroll_anchor_to_restore(), None); + + vp.set_message_height(0, 11); + vp.mark_message_height_measured(0); + vp.set_message_height(1, 8); + vp.mark_message_height_measured(1); + vp.rebuild_prefix_sums(); + + assert_eq!(vp.ready_scroll_anchor_to_restore(), Some(anchor)); + } + + #[test] + fn viewport_prioritizes_rows_above_preserved_anchor_until_restore_is_exact() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(6); + for idx in 0..6 { + vp.set_message_height(idx, 5); + } + vp.mark_heights_valid(); + vp.rebuild_prefix_sums(); + + vp.auto_scroll = false; + vp.scroll_offset = 12; + vp.scroll_target = 12; + vp.scroll_pos = 12.0; + + let _ = vp.on_frame(40, 24); + vp.ensure_resize_remeasure_anchor(2, 3, 6); + + assert_eq!(vp.next_resize_remeasure_index(6), Some(1)); + assert_eq!(vp.next_resize_remeasure_index(6), Some(0)); + assert_eq!(vp.next_resize_remeasure_index(6), Some(4)); + } + + #[allow(clippy::cast_precision_loss)] + #[test] + fn viewport_global_remeasure_preserves_anchor_while_prefix_above_converges() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.sync_message_count(6); + for idx in 0..6 { + vp.set_message_height(idx, 5); + } + vp.mark_heights_valid(); + vp.rebuild_prefix_sums(); + + vp.auto_scroll = false; + vp.scroll_offset = 17; + vp.scroll_target = 17; + vp.scroll_pos = 17.0; + + vp.invalidate_all_messages(LayoutRemeasureReason::Global); + let anchor = + vp.scroll_anchor_to_restore().expect("global remeasure should preserve an anchor"); + assert_eq!(anchor, (3, 2)); + + vp.invalidate_message(5); + + assert_eq!(vp.remeasure_reason(), Some(LayoutRemeasureReason::MessageChanged)); + assert_eq!(vp.scroll_anchor_to_restore(), Some(anchor)); + + vp.set_message_height(0, 12); + vp.mark_message_height_measured(0); + vp.set_message_height(1, 8); + vp.mark_message_height_measured(1); + vp.rebuild_prefix_sums(); + + assert_eq!(vp.find_first_visible(vp.scroll_offset), 1); + + vp.restore_scroll_anchor(anchor.0, anchor.1); + + assert_eq!(vp.find_first_visible(vp.scroll_offset), 3); + assert_eq!(vp.scroll_offset, 27); + } + + #[test] + fn viewport_prefix_sums_basic() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.set_message_height(0, 5); + vp.set_message_height(1, 10); + vp.set_message_height(2, 3); + vp.rebuild_prefix_sums(); + assert_eq!(vp.total_message_height(), 18); + assert_eq!(vp.cumulative_height_before(0), 0); + assert_eq!(vp.cumulative_height_before(1), 5); + assert_eq!(vp.cumulative_height_before(2), 15); + } + + #[test] + fn viewport_prefix_sums_streaming_fast_path() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.set_message_height(0, 5); + vp.set_message_height(1, 10); + vp.rebuild_prefix_sums(); + assert_eq!(vp.total_message_height(), 15); + + // Simulate streaming: last message grows + vp.set_message_height(1, 20); + vp.rebuild_prefix_sums(); // should hit fast path + assert_eq!(vp.total_message_height(), 25); + assert_eq!(vp.cumulative_height_before(1), 5); + } + + #[test] + fn viewport_find_first_visible() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.set_message_height(0, 10); + vp.set_message_height(1, 10); + vp.set_message_height(2, 10); + vp.rebuild_prefix_sums(); + + assert_eq!(vp.find_first_visible(0), 0); + assert_eq!(vp.find_first_visible(10), 1); + assert_eq!(vp.find_first_visible(15), 1); + assert_eq!(vp.find_first_visible(20), 2); + } + + #[test] + fn viewport_find_first_visible_handles_offsets_before_first_boundary() { + let mut vp = ChatViewport::new(); + let _ = vp.on_frame(80, 24); + vp.set_message_height(0, 10); + vp.set_message_height(1, 10); + vp.rebuild_prefix_sums(); + + assert_eq!(vp.find_first_visible(0), 0); + assert_eq!(vp.find_first_visible(5), 0); + assert_eq!(vp.find_first_visible(15), 1); + } + + #[test] + fn viewport_scroll_up_down() { + let mut vp = ChatViewport::new(); + vp.scroll_target = 20; + vp.scroll_pos = 20.0; + vp.scroll_offset = 20; + vp.auto_scroll = true; + + vp.scroll_up(5); + assert_eq!(vp.scroll_target, 15); + assert!((vp.scroll_pos - 15.0).abs() < f32::EPSILON); + assert_eq!(vp.scroll_offset, 15); + assert!(!vp.auto_scroll); // disabled on manual scroll + + vp.scroll_down(3); + assert_eq!(vp.scroll_target, 18); + assert!((vp.scroll_pos - 18.0).abs() < f32::EPSILON); + assert_eq!(vp.scroll_offset, 18); + assert!(!vp.auto_scroll); // not re-engaged by scroll_down + } + + #[test] + fn viewport_scroll_up_saturates() { + let mut vp = ChatViewport::new(); + vp.scroll_target = 2; + vp.scroll_pos = 2.0; + vp.scroll_offset = 2; + vp.scroll_up(10); + assert_eq!(vp.scroll_target, 0); + assert!(vp.scroll_pos.abs() < f32::EPSILON); + assert_eq!(vp.scroll_offset, 0); + } + + #[test] + fn viewport_engage_auto_scroll() { + let mut vp = ChatViewport::new(); + vp.auto_scroll = false; + vp.engage_auto_scroll(); + assert!(vp.auto_scroll); + } + + #[test] + fn viewport_default_eq_new() { + let a = ChatViewport::new(); + let b = ChatViewport::default(); + assert_eq!(a.width, b.width); + assert_eq!(a.auto_scroll, b.auto_scroll); + assert_eq!(a.message_heights.len(), b.message_heights.len()); + } + + #[test] + fn focus_owner_defaults_to_input() { + let app = make_test_app(); + assert_eq!(app.focus_owner(), FocusOwner::Input); + } + + #[test] + fn focus_owner_todo_when_panel_open_and_focused() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + } + + #[test] + fn focus_owner_permission_overrides_todo() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + app.pending_interaction_ids.push("perm-1".into()); + app.claim_focus_target(FocusTarget::Permission); + assert_eq!(app.focus_owner(), FocusOwner::Permission); + } + + #[test] + fn focus_owner_mention_overrides_permission_and_todo() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + app.pending_interaction_ids.push("perm-1".into()); + app.claim_focus_target(FocusTarget::Permission); + app.slash = Some(SlashState { + trigger_row: 0, + trigger_col: 0, + query: String::new(), + context: SlashContext::CommandName, + candidates: vec![SlashCandidate { + insert_value: "/config".into(), + primary: "/config".into(), + secondary: Some("Open settings".into()), + }], + dialog: dialog::DialogState::default(), + }); + app.claim_focus_target(FocusTarget::Mention); + assert_eq!(app.focus_owner(), FocusOwner::Mention); + } + + #[test] + fn focus_owner_falls_back_to_input_when_claim_is_not_available() { + let mut app = make_test_app(); + app.claim_focus_target(FocusTarget::TodoList); + assert_eq!(app.focus_owner(), FocusOwner::Input); + } + + #[test] + fn claim_and_release_focus_target() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.claim_focus_target(FocusTarget::TodoList); + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + app.release_focus_target(FocusTarget::TodoList); + assert_eq!(app.focus_owner(), FocusOwner::Input); + } + + #[test] + fn latest_claim_wins_across_equal_targets() { + let mut app = make_test_app(); + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + app.show_todo_panel = true; + app.slash = Some(SlashState { + trigger_row: 0, + trigger_col: 0, + query: String::new(), + context: SlashContext::CommandName, + candidates: vec![SlashCandidate { + insert_value: "/config".into(), + primary: "/config".into(), + secondary: Some("Open settings".into()), + }], + dialog: dialog::DialogState::default(), + }); + app.pending_interaction_ids.push("perm-1".into()); + + app.claim_focus_target(FocusTarget::TodoList); + assert_eq!(app.focus_owner(), FocusOwner::TodoList); + + app.claim_focus_target(FocusTarget::Permission); + assert_eq!(app.focus_owner(), FocusOwner::Permission); + + app.claim_focus_target(FocusTarget::Mention); + assert_eq!(app.focus_owner(), FocusOwner::Mention); + + app.release_focus_target(FocusTarget::Mention); + assert_eq!(app.focus_owner(), FocusOwner::Permission); + } + + // --- InvalidationLevel tests --- + + #[test] + fn invalidate_single_tail_preserves_prefix_sums() { + let mut app = make_test_app(); + app.messages.push(user_text_message("a")); + app.messages.push(user_text_message("b")); + app.messages.push(user_text_message("c")); + let _ = app.viewport.on_frame(80, 24); + app.viewport.set_message_height(0, 5); + app.viewport.set_message_height(1, 10); + app.viewport.set_message_height(2, 3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + + app.invalidate_layout(InvalidationLevel::MessageChanged(2)); // tail + + assert_eq!(app.viewport.oldest_stale_index(), Some(2)); + assert_eq!(app.viewport.prefix_dirty_from(), Some(2)); + assert_eq!(app.viewport.prefix_sums_width, 0); + } + + #[test] + fn invalidate_single_nontail_invalidates_prefix_sums() { + let mut app = make_test_app(); + app.messages.push(user_text_message("a")); + app.messages.push(user_text_message("b")); + app.messages.push(user_text_message("c")); + let _ = app.viewport.on_frame(80, 24); + app.viewport.set_message_height(0, 5); + app.viewport.set_message_height(1, 10); + app.viewport.set_message_height(2, 3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + + app.invalidate_layout(InvalidationLevel::MessageChanged(1)); // non-tail + + assert_eq!(app.viewport.oldest_stale_index(), Some(1)); + assert_eq!(app.viewport.prefix_dirty_from(), Some(1)); + assert_eq!(app.viewport.prefix_sums_width, 0); + } + + #[test] + fn invalidate_from_always_invalidates_prefix_sums() { + let mut app = make_test_app(); + app.messages.push(user_text_message("a")); + app.messages.push(user_text_message("b")); + app.messages.push(user_text_message("c")); + let _ = app.viewport.on_frame(80, 24); + app.viewport.set_message_height(0, 5); + app.viewport.set_message_height(1, 10); + app.viewport.set_message_height(2, 3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + assert_ne!(app.viewport.prefix_sums_width, 0); + + // From at tail index still invalidates prefix sums (unlike Single). + app.invalidate_layout(InvalidationLevel::MessagesFrom(2)); + + assert_eq!(app.viewport.oldest_stale_index(), Some(2)); + assert_eq!(app.viewport.prefix_dirty_from(), Some(2)); + assert_eq!(app.viewport.prefix_sums_width, 0); + } + + #[test] + fn invalidate_from_zero_matches_old_mark_all() { + let mut app = make_test_app(); + app.messages.push(user_text_message("a")); + app.messages.push(user_text_message("b")); + app.messages.push(user_text_message("c")); + let _ = app.viewport.on_frame(80, 24); + app.viewport.set_message_height(0, 5); + app.viewport.set_message_height(1, 10); + app.viewport.set_message_height(2, 3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + + app.invalidate_layout(InvalidationLevel::MessagesFrom(0)); + + assert_eq!(app.viewport.oldest_stale_index(), Some(0)); + assert_eq!(app.viewport.prefix_dirty_from(), Some(0)); + assert_eq!(app.viewport.prefix_sums_width, 0); + } + + #[test] + fn invalidate_global_bumps_generation() { + let mut app = make_test_app(); + app.messages.push(user_text_message("a")); + app.messages.push(user_text_message("b")); + app.messages.push(user_text_message("c")); + let _ = app.viewport.on_frame(80, 24); + app.viewport.sync_message_count(3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + let gen_before = app.viewport.layout_generation; + + app.invalidate_layout(InvalidationLevel::Global); + + assert_eq!(app.viewport.oldest_stale_index(), Some(0)); + assert_eq!(app.viewport.prefix_dirty_from(), Some(0)); + assert_eq!(app.viewport.prefix_sums_width, 0); + assert_eq!(app.viewport.layout_generation, gen_before + 1); + } + + #[test] + fn invalidate_global_noop_on_empty() { + let mut app = make_test_app(); + assert!(app.messages.is_empty()); + let gen_before = app.viewport.layout_generation; + + app.invalidate_layout(InvalidationLevel::Global); + + assert!(app.viewport.oldest_stale_index().is_none()); + assert_eq!(app.viewport.layout_generation, gen_before); + } + + #[test] + fn invalidate_message_tracks_oldest_stale_index() { + let mut app = make_test_app(); + // Need enough messages so all indices are non-tail for consistent behavior. + for _ in 0..10 { + app.messages.push(user_text_message("x")); + } + app.viewport.sync_message_count(10); + app.viewport.mark_heights_valid(); + + app.invalidate_layout(InvalidationLevel::MessageChanged(5)); + app.invalidate_layout(InvalidationLevel::MessageChanged(2)); + app.invalidate_layout(InvalidationLevel::MessageChanged(7)); + + assert_eq!(app.viewport.oldest_stale_index(), Some(2)); + } + + #[test] + fn invalidation_level_eq_and_debug() { + assert_eq!(InvalidationLevel::MessageChanged(5), InvalidationLevel::MessageChanged(5)); + assert_ne!(InvalidationLevel::MessageChanged(5), InvalidationLevel::MessagesFrom(5)); + assert_eq!(InvalidationLevel::Global, InvalidationLevel::Global); + assert_eq!(InvalidationLevel::Resize, InvalidationLevel::Resize); + // Debug derive works + let dbg = format!("{:?}", InvalidationLevel::MessagesFrom(3)); + assert!(dbg.contains("MessagesFrom")); + } +} diff --git a/claude-code-rust/src/app/state/render_budget.rs b/claude-code-rust/src/app/state/render_budget.rs new file mode 100644 index 0000000..e08b416 --- /dev/null +++ b/claude-code-rust/src/app/state/render_budget.rs @@ -0,0 +1,311 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::messages::MessageBlock; +use super::types::{AppStatus, CacheBudgetEnforceStats}; +use crate::agent::model; +use std::cmp::Reverse; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct RenderCacheEvictionKey { + pub(super) last_access_tick: u64, + pub(super) bytes_desc: Reverse, + pub(super) msg_idx: usize, + pub(super) block_idx: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct RenderCacheSlotState { + pub(super) cached_bytes: usize, + pub(super) last_access_tick: u64, + pub(super) protected: bool, +} + +impl super::App { + #[must_use] + fn is_streaming_tail_protected(&self) -> bool { + matches!(self.status, AppStatus::Thinking | AppStatus::Running) + } + + #[must_use] + fn protected_streaming_message_idx(&self) -> Option { + if !self.is_streaming_tail_protected() { + return None; + } + self.active_turn_assistant_idx().or_else(|| self.messages.len().checked_sub(1)) + } + + #[must_use] + fn block_cache(block: &MessageBlock) -> &super::BlockCache { + match block { + MessageBlock::Text(block) => &block.cache, + MessageBlock::Welcome(welcome) => &welcome.cache, + MessageBlock::ToolCall(tc) => &tc.cache, + } + } + + #[must_use] + fn is_render_cache_block_protected(&self, msg_idx: usize, block_idx: usize) -> bool { + let tail_protected = self.protected_streaming_message_idx() == Some(msg_idx); + let Some(block) = self.messages.get(msg_idx).and_then(|msg| msg.blocks.get(block_idx)) + else { + return false; + }; + let tool_protected = matches!( + block, + MessageBlock::ToolCall(tc) + if matches!( + tc.status, + model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress + ) + ); + tail_protected || tool_protected + } + + #[must_use] + fn render_cache_slot_key( + msg_idx: usize, + block_idx: usize, + slot: &RenderCacheSlotState, + ) -> Option { + (!slot.protected && slot.cached_bytes > 0).then_some(RenderCacheEvictionKey { + last_access_tick: slot.last_access_tick, + bytes_desc: Reverse(slot.cached_bytes), + msg_idx, + block_idx, + }) + } + + #[must_use] + fn render_cache_slots_match_messages(&self) -> bool { + self.render_cache_slots.len() == self.messages.len() + && self + .render_cache_slots + .iter() + .zip(&self.messages) + .all(|(slots, msg)| slots.len() == msg.blocks.len()) + } + + pub(crate) fn rebuild_render_cache_accounting(&mut self) { + self.render_cache_slots.clear(); + self.render_cache_slots.reserve(self.messages.len()); + self.render_cache_total_bytes = 0; + self.render_cache_protected_bytes = 0; + self.render_cache_evictable.clear(); + + let protected_tail = self.protected_streaming_message_idx(); + for (msg_idx, msg) in self.messages.iter().enumerate() { + let mut slots = Vec::with_capacity(msg.blocks.len()); + for (block_idx, block) in msg.blocks.iter().enumerate() { + let cache = Self::block_cache(block); + let cached_bytes = cache.cached_bytes(); + let protected = protected_tail == Some(msg_idx) + || matches!( + block, + MessageBlock::ToolCall(tc) + if matches!( + tc.status, + model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress + ) + ); + let slot = RenderCacheSlotState { + cached_bytes, + last_access_tick: cache.last_access_tick(), + protected, + }; + self.render_cache_total_bytes = + self.render_cache_total_bytes.saturating_add(cached_bytes); + if protected { + self.render_cache_protected_bytes = + self.render_cache_protected_bytes.saturating_add(cached_bytes); + } else if let Some(key) = Self::render_cache_slot_key(msg_idx, block_idx, &slot) { + self.render_cache_evictable.insert(key); + } + slots.push(slot); + } + self.render_cache_slots.push(slots); + } + self.render_cache_tail_msg_idx = protected_tail; + } + + pub(crate) fn ensure_render_cache_accounting(&mut self) { + if !self.render_cache_slots_match_messages() { + self.rebuild_render_cache_accounting(); + } + } + + pub(crate) fn sync_render_cache_slot(&mut self, msg_idx: usize, block_idx: usize) { + self.ensure_render_cache_accounting(); + let Some(old_slot) = + self.render_cache_slots.get(msg_idx).and_then(|slots| slots.get(block_idx)).copied() + else { + self.rebuild_render_cache_accounting(); + return; + }; + + if let Some(old_key) = Self::render_cache_slot_key(msg_idx, block_idx, &old_slot) { + self.render_cache_evictable.remove(&old_key); + } + self.render_cache_total_bytes = + self.render_cache_total_bytes.saturating_sub(old_slot.cached_bytes); + if old_slot.protected { + self.render_cache_protected_bytes = + self.render_cache_protected_bytes.saturating_sub(old_slot.cached_bytes); + } + + let Some(block) = self.messages.get(msg_idx).and_then(|msg| msg.blocks.get(block_idx)) + else { + self.rebuild_render_cache_accounting(); + return; + }; + let cache = Self::block_cache(block); + let new_slot = RenderCacheSlotState { + cached_bytes: cache.cached_bytes(), + last_access_tick: cache.last_access_tick(), + protected: self.is_render_cache_block_protected(msg_idx, block_idx), + }; + if let Some(slots) = self.render_cache_slots.get_mut(msg_idx) { + if let Some(slot) = slots.get_mut(block_idx) { + *slot = new_slot; + } else { + self.rebuild_render_cache_accounting(); + return; + } + } else { + self.rebuild_render_cache_accounting(); + return; + } + + self.render_cache_total_bytes = + self.render_cache_total_bytes.saturating_add(new_slot.cached_bytes); + if new_slot.protected { + self.render_cache_protected_bytes = + self.render_cache_protected_bytes.saturating_add(new_slot.cached_bytes); + } else if let Some(new_key) = Self::render_cache_slot_key(msg_idx, block_idx, &new_slot) { + self.render_cache_evictable.insert(new_key); + } + } + + pub(crate) fn sync_render_cache_message(&mut self, msg_idx: usize) { + self.ensure_render_cache_accounting(); + let block_count = self.messages.get(msg_idx).map_or(0, |msg| msg.blocks.len()); + if self.render_cache_slots.get(msg_idx).map_or(usize::MAX, Vec::len) != block_count { + self.rebuild_render_cache_accounting(); + return; + } + for block_idx in 0..block_count { + self.sync_render_cache_slot(msg_idx, block_idx); + } + } + + fn refresh_tail_message_cache_protection(&mut self) { + self.ensure_render_cache_accounting(); + let next_tail = self.protected_streaming_message_idx(); + if self.render_cache_tail_msg_idx == next_tail { + return; + } + + let previous_tail = self.render_cache_tail_msg_idx; + self.render_cache_tail_msg_idx = next_tail; + + if let Some(msg_idx) = previous_tail { + self.sync_render_cache_message(msg_idx); + } + if let Some(msg_idx) = next_tail + && Some(msg_idx) != previous_tail + { + self.sync_render_cache_message(msg_idx); + } + } + + pub(crate) fn note_render_cache_structure_changed(&mut self) { + self.rebuild_render_cache_accounting(); + } + + fn refresh_render_cache_eviction_order(&mut self) { + self.ensure_render_cache_accounting(); + self.render_cache_evictable.clear(); + + for (msg_idx, msg) in self.messages.iter().enumerate() { + for (block_idx, block) in msg.blocks.iter().enumerate() { + let cache = Self::block_cache(block); + let protected = self.is_render_cache_block_protected(msg_idx, block_idx); + let slot = RenderCacheSlotState { + cached_bytes: cache.cached_bytes(), + last_access_tick: cache.last_access_tick(), + protected, + }; + if let Some(slots) = self.render_cache_slots.get_mut(msg_idx) + && let Some(existing) = slots.get_mut(block_idx) + { + existing.last_access_tick = slot.last_access_tick; + existing.protected = slot.protected; + } + if let Some(key) = Self::render_cache_slot_key(msg_idx, block_idx, &slot) { + self.render_cache_evictable.insert(key); + } + } + } + } + + pub fn enforce_render_cache_budget(&mut self) -> CacheBudgetEnforceStats { + let mut stats = CacheBudgetEnforceStats::default(); + self.refresh_tail_message_cache_protection(); + stats.total_before_bytes = self.render_cache_total_bytes; + stats.protected_bytes = self.render_cache_protected_bytes; + + // Budget comparison uses only non-protected (evictable) bytes. + let budgeted_bytes = stats.total_before_bytes.saturating_sub(stats.protected_bytes); + + if budgeted_bytes <= self.render_cache_budget.max_bytes { + self.render_cache_budget.last_total_bytes = budgeted_bytes; + self.render_cache_budget.last_evicted_bytes = 0; + stats.total_after_bytes = stats.total_before_bytes; + return stats; + } + + self.refresh_render_cache_eviction_order(); + let mut current_budgeted = budgeted_bytes; + stats.total_after_bytes = stats.total_before_bytes; + + while let Some(slot) = self.render_cache_evictable.first().copied() { + if current_budgeted <= self.render_cache_budget.max_bytes { + break; + } + self.render_cache_evictable.remove(&slot); + let removed = self.evict_cache_slot(slot.msg_idx, slot.block_idx); + if removed == 0 { + continue; + } + current_budgeted = current_budgeted.saturating_sub(removed); + stats.total_after_bytes = stats.total_after_bytes.saturating_sub(removed); + stats.evicted_bytes = stats.evicted_bytes.saturating_add(removed); + stats.evicted_blocks = stats.evicted_blocks.saturating_add(1); + } + + self.render_cache_budget.last_total_bytes = current_budgeted; + self.render_cache_budget.last_evicted_bytes = stats.evicted_bytes; + self.render_cache_budget.total_evictions = + self.render_cache_budget.total_evictions.saturating_add(stats.evicted_blocks); + + stats + } + + fn evict_cache_slot(&mut self, msg_idx: usize, block_idx: usize) -> usize { + let Some(msg) = self.messages.get_mut(msg_idx) else { + return 0; + }; + let Some(block) = msg.blocks.get_mut(block_idx) else { + return 0; + }; + let removed = match block { + MessageBlock::Text(block) => block.cache.evict_cached_render(), + MessageBlock::Welcome(welcome) => welcome.cache.evict_cached_render(), + MessageBlock::ToolCall(tc) => tc.cache.evict_cached_render(), + }; + if removed > 0 { + self.sync_render_cache_slot(msg_idx, block_idx); + } + removed + } +} diff --git a/claude-code-rust/src/app/state/tool_call_info.rs b/claude-code-rust/src/app/state/tool_call_info.rs new file mode 100644 index 0000000..b8729b9 --- /dev/null +++ b/claude-code-rust/src/app/state/tool_call_info.rs @@ -0,0 +1,195 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::block_cache::BlockCache; +use crate::agent::model; + +pub struct ToolCallInfo { + pub id: String, + pub title: String, + /// The SDK tool name from `meta.claudeCode.toolName` when available. + /// Falls back to a derived name when metadata is absent. + pub sdk_tool_name: String, + pub raw_input: Option, + pub raw_input_bytes: usize, + pub output_metadata: Option, + pub status: model::ToolCallStatus, + pub content: Vec, + /// Hidden tool calls are subagent children - not rendered directly. + pub hidden: bool, + /// Terminal ID if this is a Bash-like SDK tool call with a running/completed terminal. + pub terminal_id: Option, + /// The shell command that was executed (e.g. "echo hello && ls -la"). + pub terminal_command: Option, + /// Snapshot of terminal output, updated each frame while `InProgress`. + pub terminal_output: Option, + /// Length of terminal buffer at last snapshot - used to skip O(n) re-snapshots + /// when the buffer hasn't grown. + pub terminal_output_len: usize, + /// Number of terminal output bytes consumed for incremental append updates. + pub terminal_bytes_seen: usize, + /// Current terminal snapshot ingestion mode. + pub terminal_snapshot_mode: TerminalSnapshotMode, + /// Monotonic generation for render-affecting changes. + pub render_epoch: u64, + /// Monotonic generation for layout-affecting changes. + pub layout_epoch: u64, + /// Last measured width used by tool-call height cache. + pub last_measured_width: u16, + /// Last measured visual height in wrapped rows. + pub last_measured_height: usize, + /// Layout epoch used for the last measured height. + pub last_measured_layout_epoch: u64, + /// Global layout generation used for the last measured height. + pub last_measured_layout_generation: u64, + /// Per-block render cache for this tool call. + pub cache: BlockCache, + /// Inline permission prompt - rendered inside this tool call block. + pub pending_permission: Option, + /// Inline question prompt from `AskUserQuestion`. + pub pending_question: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalSnapshotMode { + AppendOnly, + ReplaceSnapshot, +} + +impl ToolCallInfo { + pub(crate) fn estimate_json_value_bytes(value: &serde_json::Value) -> usize { + serde_json::to_string(value).map_or(0, |json| json.len()) + } + + #[must_use] + pub fn is_execute_tool(&self) -> bool { + is_execute_tool_name(&self.sdk_tool_name) + } + + #[must_use] + pub fn is_ask_question_tool(&self) -> bool { + is_ask_question_tool_name(&self.sdk_tool_name) + } + + #[must_use] + pub fn is_exit_plan_mode_tool(&self) -> bool { + is_exit_plan_mode_tool_name(&self.sdk_tool_name) + } + + #[must_use] + pub fn is_ultraplan(&self) -> bool { + self.output_metadata + .as_ref() + .and_then(|metadata| metadata.exit_plan_mode.as_ref()) + .and_then(|metadata| metadata.is_ultraplan) + .unwrap_or(false) + } + + #[must_use] + pub fn assistant_auto_backgrounded(&self) -> bool { + self.output_metadata + .as_ref() + .and_then(|metadata| metadata.bash.as_ref()) + .and_then(|metadata| metadata.assistant_auto_backgrounded) + .unwrap_or(false) + } + + #[must_use] + pub fn token_saver_active(&self) -> bool { + self.output_metadata + .as_ref() + .and_then(|metadata| metadata.bash.as_ref()) + .and_then(|metadata| metadata.token_saver_active) + .unwrap_or(false) + } + + #[must_use] + pub fn verification_nudge_needed(&self) -> bool { + self.output_metadata + .as_ref() + .and_then(|metadata| metadata.todo_write.as_ref()) + .and_then(|metadata| metadata.verification_nudge_needed) + .unwrap_or(false) + } + + /// Mark render cache for this tool call as stale. + pub fn mark_tool_call_render_dirty(&mut self) { + crate::perf::mark("tc_invalidations_requested"); + self.render_epoch = self.render_epoch.wrapping_add(1); + self.cache.invalidate(); + crate::perf::mark("tc_invalidations_applied"); + } + + /// Mark layout cache for this tool call as stale. + pub fn mark_tool_call_layout_dirty(&mut self) { + self.layout_epoch = self.layout_epoch.wrapping_add(1); + self.last_measured_width = 0; + self.last_measured_height = 0; + self.last_measured_layout_epoch = 0; + self.last_measured_layout_generation = 0; + self.mark_tool_call_render_dirty(); + } + + #[must_use] + pub fn cache_measurement_key_matches(&self, width: u16, layout_generation: u64) -> bool { + self.last_measured_width == width + && self.last_measured_layout_epoch == self.layout_epoch + && self.last_measured_layout_generation == layout_generation + } + + pub fn record_measured_height(&mut self, width: u16, height: usize, layout_generation: u64) { + self.last_measured_width = width; + self.last_measured_height = height; + self.last_measured_layout_epoch = self.layout_epoch; + self.last_measured_layout_generation = layout_generation; + } + + pub fn set_raw_input(&mut self, raw_input: Option) -> bool { + if self.raw_input == raw_input { + return false; + } + self.raw_input_bytes = raw_input.as_ref().map_or(0, Self::estimate_json_value_bytes); + self.raw_input = raw_input; + true + } +} + +#[must_use] +pub fn is_execute_tool_name(tool_name: &str) -> bool { + tool_name.eq_ignore_ascii_case("bash") +} + +#[must_use] +pub fn is_ask_question_tool_name(tool_name: &str) -> bool { + tool_name.eq_ignore_ascii_case("askuserquestion") +} + +#[must_use] +pub fn is_exit_plan_mode_tool_name(tool_name: &str) -> bool { + tool_name.eq_ignore_ascii_case("exitplanmode") +} + +/// Permission state stored inline on a `ToolCallInfo`, so the permission +/// controls render inside the tool call block (unified edit/permission UX). +pub struct InlinePermission { + pub options: Vec, + pub response_tx: tokio::sync::oneshot::Sender, + pub selected_index: usize, + /// Whether this permission currently has keyboard focus. + /// When multiple permissions are pending, only the focused one + /// shows the selection arrow and accepts Left/Right/Enter input. + pub focused: bool, +} + +pub struct InlineQuestion { + pub prompt: model::QuestionPrompt, + pub response_tx: tokio::sync::oneshot::Sender, + pub focused_option_index: usize, + pub selected_option_indices: std::collections::BTreeSet, + pub notes: String, + pub notes_cursor: usize, + pub editing_notes: bool, + pub focused: bool, + pub question_index: usize, + pub total_questions: usize, +} diff --git a/claude-code-rust/src/app/state/types.rs b/claude-code-rust/src/app/state/types.rs new file mode 100644 index 0000000..9ffcae7 --- /dev/null +++ b/claude-code-rust/src/app/state/types.rs @@ -0,0 +1,261 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::agent::model; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModeInfo { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModeState { + pub current_mode_id: String, + pub current_mode_name: String, + pub available_modes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HelpView { + #[default] + Keys, + SlashCommands, + Subagents, +} + +/// Login hint displayed when authentication is required during connection. +/// Rendered as a banner above the input field. +pub struct LoginHint { + pub method_name: String, + pub method_description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingCommandAck { + CurrentModeUpdate, + ConfigOptionUpdate { option_id: String }, +} + +/// A single todo item from Claude's `TodoWrite` tool call. +#[derive(Debug, Clone)] +pub struct TodoItem { + pub content: String, + pub status: TodoStatus, + pub active_form: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TodoStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecentSessionInfo { + pub session_id: String, + pub summary: String, + pub last_modified_ms: u64, + pub file_size_bytes: u64, + pub cwd: Option, + pub git_branch: Option, + pub custom_title: Option, + pub first_prompt: Option, +} + +#[derive(Debug, Clone, PartialEq, Default)] +#[allow(clippy::struct_field_names)] +pub struct MessageUsage { + pub input_tokens: Option, + pub cache_read_tokens: Option, + pub cache_write_tokens: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UsageSourceMode { + #[default] + Auto, + Oauth, + Cli, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsageSourceKind { + Oauth, + Cli, +} + +impl UsageSourceKind { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Oauth => "oauth", + Self::Cli => "cli", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct UsageWindow { + pub label: &'static str, + pub utilization: f64, + pub resets_at: Option, + pub reset_description: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ExtraUsage { + pub monthly_limit: Option, + pub used_credits: Option, + pub utilization: Option, + pub currency: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct UsageSnapshot { + pub source: UsageSourceKind, + pub fetched_at: std::time::SystemTime, + pub five_hour: Option, + pub seven_day: Option, + pub seven_day_opus: Option, + pub seven_day_sonnet: Option, + pub extra_usage: Option, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct UsageState { + pub snapshot: Option, + pub in_flight: bool, + pub last_error: Option, + pub active_source: UsageSourceMode, + pub last_attempted_source: Option, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct SessionUsageState { + pub last_compaction_trigger: Option, + pub last_compaction_pre_tokens: Option, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct McpState { + pub servers: Vec, + pub in_flight: bool, + pub last_error: Option, + pub pending_elicitation: Option, +} + +pub const DEFAULT_RENDER_CACHE_BUDGET_BYTES: usize = 24 * 1024 * 1024; +pub const DEFAULT_HISTORY_RETENTION_MAX_BYTES: usize = 64 * 1024 * 1024; +pub const SUBAGENT_THINKING_DEBOUNCE: Duration = Duration::from_millis(1_500); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RenderCacheBudget { + pub max_bytes: usize, + pub last_total_bytes: usize, + pub last_evicted_bytes: usize, + pub total_evictions: usize, +} + +impl Default for RenderCacheBudget { + fn default() -> Self { + Self { + max_bytes: DEFAULT_RENDER_CACHE_BUDGET_BYTES, + last_total_bytes: 0, + last_evicted_bytes: 0, + total_evictions: 0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HistoryRetentionPolicy { + pub max_bytes: usize, +} + +impl Default for HistoryRetentionPolicy { + fn default() -> Self { + Self { max_bytes: DEFAULT_HISTORY_RETENTION_MAX_BYTES } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct HistoryRetentionStats { + pub total_before_bytes: usize, + pub total_after_bytes: usize, + pub dropped_messages: usize, + pub dropped_bytes: usize, + pub total_dropped_messages: usize, + pub total_dropped_bytes: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CacheBudgetEnforceStats { + pub total_before_bytes: usize, + pub total_after_bytes: usize, + pub evicted_bytes: usize, + pub evicted_blocks: usize, + /// Bytes in protected (non-evictable) blocks excluded from the budget comparison. + pub protected_bytes: usize, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum AppStatus { + /// Waiting for bridge adapter connection (TUI shown, input disabled). + Connecting, + /// A slash command is in flight (input disabled, spinner shown). + CommandPending, + Ready, + Thinking, + Running, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCallScope { + MainAgent, + Subagent, + Task, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CancelOrigin { + Manual, + AutoQueue, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionKind { + Chat, + Input, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectionPoint { + pub row: usize, + pub col: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectionState { + pub kind: SelectionKind, + pub start: SelectionPoint, + pub end: SelectionPoint, + pub dragging: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollbarDragState { + /// Row offset from thumb top where the initial click happened. + pub thumb_grab_offset: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PasteSessionState { + pub id: u64, + pub start: SelectionPoint, + pub placeholder_index: Option, +} diff --git a/claude-code-rust/src/app/state/viewport.rs b/claude-code-rust/src/app/state/viewport.rs new file mode 100644 index 0000000..ea976a5 --- /dev/null +++ b/claude-code-rust/src/app/state/viewport.rs @@ -0,0 +1,829 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +/// Describes the intent behind a layout invalidation. +/// +/// The viewport now tracks per-message staleness, prefix-sum dirtiness, and +/// queued remeasurement separately. Do not add a bounded range variant unless +/// the underlying state can represent disjoint stale spans directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayoutInvalidation { + /// One message's content changed (tool status, permission UI, terminal output). + /// The changed message must be remeasured exactly on the next frame. + MessageChanged(usize), + /// Messages from `start` onward may have changed structurally (insert/remove/reindex). + MessagesFrom(usize), + /// Terminal geometry changed. Handled internally by `on_frame()`. + /// Included for completeness; not dispatched through `App::invalidate_layout()`. + Resize, + /// Global layout change (for example, tool collapse toggle). + /// All messages become stale and `layout_generation` is bumped. + Global, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayoutRemeasureReason { + MessageChanged, + MessagesFrom, + Resize, + Global, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FrameGeometryChange { + pub width_changed: bool, + pub height_changed: bool, +} + +impl FrameGeometryChange { + #[must_use] + pub fn resized(self) -> bool { + self.width_changed || self.height_changed + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PreservedScrollAnchor { + reason: LayoutRemeasureReason, + index: usize, + offset: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LayoutRemeasurePlan { + reason: LayoutRemeasureReason, + scroll_anchor_index: usize, + scroll_anchor_offset: usize, + preserved_scroll_anchor: Option, + priority_start: usize, + priority_end: usize, + next_above: Option, + next_below: usize, + prefer_above: bool, +} + +impl LayoutRemeasurePlan { + fn new( + reason: LayoutRemeasureReason, + scroll_anchor_index: usize, + scroll_anchor_offset: usize, + preserved_scroll_anchor: Option, + priority_start: usize, + priority_end: usize, + message_count: usize, + ) -> Self { + let last_idx = message_count.saturating_sub(1); + let scroll_anchor_index = scroll_anchor_index.min(last_idx); + let priority_start = priority_start.min(last_idx); + let priority_end = priority_end.min(last_idx).max(priority_start); + let preserved_scroll_anchor = preserved_scroll_anchor.map(|anchor| PreservedScrollAnchor { + reason: anchor.reason, + index: anchor.index.min(last_idx), + offset: anchor.offset, + }); + Self { + reason, + scroll_anchor_index, + scroll_anchor_offset, + preserved_scroll_anchor, + priority_start, + priority_end, + next_above: priority_start.checked_sub(1), + next_below: priority_end.saturating_add(1).min(message_count), + prefer_above: false, + } + } + + fn from_scroll_anchor( + reason: LayoutRemeasureReason, + scroll_anchor_index: usize, + scroll_anchor_offset: usize, + preserved_scroll_anchor: Option, + message_count: usize, + ) -> Self { + Self::new( + reason, + scroll_anchor_index, + scroll_anchor_offset, + preserved_scroll_anchor, + scroll_anchor_index, + scroll_anchor_index, + message_count, + ) + } +} + +/// Single owner of all chat layout state: scroll, per-message heights, and prefix sums. +/// +/// Consolidates state previously scattered across `App` (scroll fields, prefix sums), +/// `ChatMessage` (`cached_visual_height`/`cached_visual_width`), and `BlockCache` +/// (`wrapped_height`/`wrapped_width`). Per-block heights remain on `BlockCache` +/// via `set_height()` / `height_at()`, but the viewport owns the validity width +/// that governs whether those caches are considered current. +pub struct ChatViewport { + // --- Scroll --- + /// Rendered scroll offset (rounded from `scroll_pos`). + pub scroll_offset: usize, + /// Target scroll offset requested by user input or auto-scroll. + pub scroll_target: usize, + /// Smooth scroll position (fractional) for animation. + pub scroll_pos: f32, + /// Smoothed scrollbar thumb top row (fractional) for animation. + pub scrollbar_thumb_top: f32, + /// Smoothed scrollbar thumb height (fractional) for animation. + pub scrollbar_thumb_size: f32, + /// Whether to auto-scroll to bottom on new content. + pub auto_scroll: bool, + + // --- Layout --- + /// Current terminal width. Set by `on_frame()` each render cycle. + pub width: u16, + /// Current terminal height. Set by `on_frame()` each render cycle. + pub height: u16, + /// Monotonic layout generation for width/global layout-affecting changes. + /// Tool-call measurement cache keys include this to avoid stale heights. + pub layout_generation: u64, + + // --- Per-message heights --- + /// Visual height (in terminal rows) of each message, indexed by message position. + /// Retained across resize and large invalidations as a temporary estimate until + /// the queued remeasurement converges back to exact heights. + pub message_heights: Vec, + /// Width at which `message_heights` was last known to be exact for every message. + pub message_heights_width: u16, + /// Per-message exactness marker used while queued remeasurement is in flight. + pub measured_message_widths: Vec, + /// Per-message stale marker. `true` means the message must be remeasured. + pub stale_message_heights: Vec, + /// Messages that must be remeasured exactly before the general queued plan continues. + priority_remeasure: Vec, + /// Visible-first queued remeasurement plan. + pub remeasure_plan: Option, + + // --- Prefix sums --- + /// Cumulative heights: `height_prefix_sums[i]` = sum of heights `0..=i`. + /// Enables O(log n) binary search for first visible message and O(1) total height. + pub height_prefix_sums: Vec, + /// Width at which prefix sums were last rebuilt against `message_heights`. + pub prefix_sums_width: u16, + /// Oldest prefix index whose cumulative values must be rebuilt. + pub prefix_dirty_from: Option, +} + +impl ChatViewport { + /// Create a new viewport with default scroll state (auto-scroll enabled). + #[must_use] + pub fn new() -> Self { + Self { + scroll_offset: 0, + scroll_target: 0, + scroll_pos: 0.0, + scrollbar_thumb_top: 0.0, + scrollbar_thumb_size: 0.0, + auto_scroll: true, + width: 0, + height: 0, + layout_generation: 1, + message_heights: Vec::new(), + message_heights_width: 0, + measured_message_widths: Vec::new(), + stale_message_heights: Vec::new(), + priority_remeasure: Vec::new(), + remeasure_plan: None, + height_prefix_sums: Vec::new(), + prefix_sums_width: 0, + prefix_dirty_from: None, + } + } + + /// Called at top of each render frame. + /// + /// Width and height are tracked through the same entry point, but only width + /// changes invalidate message layout. Height changes are viewport-only. + #[must_use] + pub fn on_frame(&mut self, width: u16, height: u16) -> FrameGeometryChange { + let change = FrameGeometryChange { + width_changed: self.width != 0 && self.width != width, + height_changed: self.height != 0 && self.height != height, + }; + if change.resized() { + tracing::debug!( + "RESIZE: width {} -> {}, height {} -> {}, scroll_target={}, auto_scroll={}", + self.width, + width, + self.height, + height, + self.scroll_target, + self.auto_scroll + ); + } + if change.width_changed { + self.handle_width_resize(); + } + self.width = width; + self.height = height; + change + } + + /// Invalidate message layout caches when terminal width changes. + /// + /// Old message heights remain as approximations so the next frame can keep + /// using a stable estimated prefix-sum model while queued remeasurement converges. + fn handle_width_resize(&mut self) { + if self.message_heights.is_empty() { + self.message_heights_width = 0; + self.prefix_sums_width = 0; + self.prefix_dirty_from = None; + self.remeasure_plan = None; + self.priority_remeasure.clear(); + self.layout_generation = self.layout_generation.wrapping_add(1); + return; + } + + self.message_heights_width = 0; + self.measured_message_widths.fill(0); + self.stale_message_heights.fill(true); + self.priority_remeasure.clear(); + self.mark_prefix_sums_dirty_from(0); + self.schedule_remeasure(LayoutRemeasureReason::Resize); + self.layout_generation = self.layout_generation.wrapping_add(1); + } + + /// Bump layout generation for non-width global layout-affecting changes. + pub fn bump_layout_generation(&mut self) { + self.layout_generation = self.layout_generation.wrapping_add(1); + } + + // --- Per-message height --- + + /// Get the cached visual height for message `idx`. Returns 0 if not yet computed. + #[must_use] + pub fn message_height(&self, idx: usize) -> usize { + self.message_heights.get(idx).copied().unwrap_or(0) + } + + /// Return the number of stale messages awaiting remeasurement. + #[must_use] + pub fn stale_message_count(&self) -> usize { + self.stale_message_heights.iter().filter(|stale| **stale).count() + } + + /// Return the oldest stale message index, if any. + #[must_use] + pub fn oldest_stale_index(&self) -> Option { + self.stale_message_heights.iter().position(|stale| *stale) + } + + /// Return the oldest prefix index whose cumulative values still need repair. + #[must_use] + pub fn prefix_dirty_from(&self) -> Option { + self.prefix_dirty_from + } + + /// Return the active queued remeasurement reason, if any. + #[must_use] + pub fn remeasure_reason(&self) -> Option { + self.remeasure_plan.map(|plan| plan.reason) + } + + /// Ensure per-message height state matches the current message count. + pub fn sync_message_count(&mut self, count: usize) { + let old_len = self.message_heights.len(); + if old_len != count { + self.message_heights.resize(count, 0); + self.measured_message_widths.resize(count, 0); + self.stale_message_heights.resize(count, false); + self.height_prefix_sums.resize(count, 0); + self.prefix_sums_width = 0; + self.message_heights_width = 0; + + if old_len < count { + self.stale_message_heights[old_len..].fill(true); + self.measured_message_widths[old_len..].fill(0); + self.mark_prefix_sums_dirty_from(old_len); + self.schedule_remeasure(LayoutRemeasureReason::MessagesFrom); + self.queue_priority_remeasure(count.saturating_sub(1)); + } else { + self.priority_remeasure.retain(|&idx| idx < count); + self.prefix_dirty_from = self.prefix_dirty_from.map(|idx| idx.min(count)); + } + } + + if count == 0 { + self.priority_remeasure.clear(); + self.remeasure_plan = None; + self.prefix_dirty_from = None; + self.height_prefix_sums.clear(); + return; + } + + if let Some(plan) = self.remeasure_plan + && (plan.scroll_anchor_index >= count + || plan.preserved_scroll_anchor.is_some_and(|anchor| anchor.index >= count) + || plan.priority_start >= count + || plan.priority_end >= count + || plan.next_below > count) + { + self.remeasure_plan = Some(LayoutRemeasurePlan::new( + plan.reason, + plan.scroll_anchor_index.min(count.saturating_sub(1)), + plan.scroll_anchor_offset, + plan.preserved_scroll_anchor, + plan.priority_start.min(count.saturating_sub(1)), + plan.priority_end.min(count.saturating_sub(1)), + count, + )); + } + + if self.has_stale_message_heights() && self.remeasure_plan.is_none() { + self.schedule_remeasure(LayoutRemeasureReason::MessagesFrom); + } + } + + /// Set the visual height for message `idx`, growing the vec if needed. + pub fn set_message_height(&mut self, idx: usize, h: usize) { + if idx >= self.message_heights.len() { + self.sync_message_count(idx + 1); + } + if self.message_heights.get(idx).copied().unwrap_or(0) != h { + self.message_heights[idx] = h; + self.mark_prefix_sums_dirty_from(idx); + } + } + + /// Mark one message height as exact for the current viewport width. + pub fn mark_message_height_measured(&mut self, idx: usize) { + if idx >= self.measured_message_widths.len() { + self.sync_message_count(idx + 1); + } + self.measured_message_widths[idx] = self.width; + self.stale_message_heights[idx] = false; + self.priority_remeasure.retain(|&pending| pending != idx); + } + + /// Return whether a message height is exact at the current width. + #[must_use] + pub fn message_height_is_current(&self, idx: usize) -> bool { + if self.stale_message_heights.get(idx).copied().unwrap_or(false) { + return false; + } + if self.message_heights_width == self.width { + return idx < self.message_heights.len(); + } + self.measured_message_widths.get(idx).copied().unwrap_or(0) == self.width + } + + /// Return whether any queued remeasurement is still active. + #[must_use] + pub fn remeasure_active(&self) -> bool { + self.remeasure_plan.is_some() + } + + /// Return whether any message height is still stale. + #[must_use] + pub fn has_stale_message_heights(&self) -> bool { + self.stale_message_heights.iter().any(|stale| *stale) + } + + /// Mark one message as stale and queue it for exact remeasurement. + pub fn invalidate_message(&mut self, idx: usize) { + if idx >= self.message_heights.len() { + self.sync_message_count(idx + 1); + } + self.message_heights_width = 0; + self.measured_message_widths[idx] = 0; + self.stale_message_heights[idx] = true; + self.queue_priority_remeasure(idx); + self.mark_prefix_sums_dirty_from(idx); + self.schedule_remeasure(LayoutRemeasureReason::MessageChanged); + } + + /// Mark every message from `idx` onward as stale and queue visible-first remeasurement. + pub fn invalidate_messages_from(&mut self, idx: usize) { + if self.message_heights.is_empty() { + return; + } + let start = idx.min(self.message_heights.len().saturating_sub(1)); + self.message_heights_width = 0; + self.measured_message_widths[start..].fill(0); + self.stale_message_heights[start..].fill(true); + self.mark_prefix_sums_dirty_from(start); + self.schedule_remeasure(LayoutRemeasureReason::MessagesFrom); + } + + /// Mark all messages stale and queue visible-first remeasurement. + pub fn invalidate_all_messages(&mut self, reason: LayoutRemeasureReason) { + if self.message_heights.is_empty() { + return; + } + self.message_heights_width = 0; + self.measured_message_widths.fill(0); + self.stale_message_heights.fill(true); + self.priority_remeasure.clear(); + self.mark_prefix_sums_dirty_from(0); + self.schedule_remeasure(reason); + } + + fn queue_priority_remeasure(&mut self, idx: usize) { + if self.priority_remeasure.contains(&idx) { + return; + } + self.priority_remeasure.push(idx); + } + + /// Pop the next message that must be remeasured before the general queue continues. + pub fn next_priority_remeasure(&mut self) -> Option { + while let Some(idx) = self.priority_remeasure.pop() { + if self.stale_message_heights.get(idx).copied().unwrap_or(false) { + return Some(idx); + } + } + None + } + + fn schedule_remeasure(&mut self, reason: LayoutRemeasureReason) { + if self.message_heights.is_empty() { + self.remeasure_plan = None; + return; + } + let (anchor_index, anchor_offset) = self.current_scroll_anchor(); + let preserved_scroll_anchor = + if matches!(reason, LayoutRemeasureReason::Resize | LayoutRemeasureReason::Global) { + Some(PreservedScrollAnchor { reason, index: anchor_index, offset: anchor_offset }) + } else { + self.remeasure_plan.and_then(|plan| plan.preserved_scroll_anchor) + }; + self.remeasure_plan = Some(LayoutRemeasurePlan::from_scroll_anchor( + reason, + anchor_index, + anchor_offset, + preserved_scroll_anchor, + self.message_heights.len(), + )); + } + + /// Capture the current message-local scroll anchor when manual scrolling is active. + #[must_use] + pub fn capture_manual_scroll_anchor(&self) -> Option<(usize, usize)> { + (!self.auto_scroll && !self.message_heights.is_empty()) + .then(|| self.current_scroll_anchor()) + } + + /// Preserve a message-local anchor across topology or lifecycle-driven layout changes. + pub fn preserve_scroll_anchor( + &mut self, + reason: LayoutRemeasureReason, + anchor_index: usize, + anchor_offset: usize, + ) { + if self.message_heights.is_empty() { + self.remeasure_plan = None; + return; + } + + let anchor = PreservedScrollAnchor { + reason, + index: anchor_index.min(self.message_heights.len().saturating_sub(1)), + offset: anchor_offset, + }; + + if let Some(plan) = self.remeasure_plan.as_mut() { + plan.preserved_scroll_anchor = Some(anchor); + return; + } + + self.remeasure_plan = Some(LayoutRemeasurePlan::from_scroll_anchor( + reason, + anchor.index, + anchor.offset, + Some(anchor), + self.message_heights.len(), + )); + } + + /// Reset the outward expansion frontiers around the current visible window. + pub fn ensure_remeasure_anchor( + &mut self, + visible_start: usize, + visible_end: usize, + message_count: usize, + ) { + if message_count == 0 || self.remeasure_plan.is_none() { + return; + } + let Some(plan) = self.remeasure_plan else { + return; + }; + let next = LayoutRemeasurePlan::new( + plan.reason, + plan.scroll_anchor_index, + plan.scroll_anchor_offset, + plan.preserved_scroll_anchor, + visible_start, + visible_end, + message_count, + ); + let needs_reanchor = self.remeasure_plan.is_some_and(|current| { + current.priority_start != next.priority_start + || current.priority_end != next.priority_end + }); + if needs_reanchor { + self.remeasure_plan = Some(next); + } + } + + /// Resume outward remeasurement from the current visible anchor. + pub fn next_remeasure_index(&mut self, message_count: usize) -> Option { + let prioritize_above_for_anchor = self.remeasure_plan.is_some_and(|plan| { + plan.preserved_scroll_anchor + .is_some_and(|anchor| !self.prefix_is_exact_through(anchor.index)) + }); + let plan = self.remeasure_plan.as_mut()?; + let choose_above = match (plan.next_above, plan.next_below < message_count) { + (Some(_), true) if prioritize_above_for_anchor => true, + (Some(_), true) => { + let choose = plan.prefer_above; + plan.prefer_above = !plan.prefer_above; + choose + } + (Some(_), false) => true, + (None, true) => false, + (None, false) => { + self.remeasure_plan = None; + return None; + } + }; + if choose_above { + let idx = plan.next_above?; + plan.next_above = idx.checked_sub(1); + Some(idx) + } else { + let idx = plan.next_below; + plan.next_below = plan.next_below.saturating_add(1); + Some(idx) + } + } + + /// Return the preserved scroll anchor that should be restored while remeasure + /// remains in flight. + #[must_use] + pub fn scroll_anchor_to_restore(&self) -> Option<(usize, usize)> { + self.remeasure_plan.and_then(|plan| { + plan.preserved_scroll_anchor.map(|anchor| (anchor.index, anchor.offset)) + }) + } + + /// Return the preserved scroll anchor only once rows above it are exact. + #[must_use] + pub fn ready_scroll_anchor_to_restore(&self) -> Option<(usize, usize)> { + self.remeasure_plan.and_then(|plan| { + plan.preserved_scroll_anchor.and_then(|anchor| { + self.prefix_is_exact_through(anchor.index).then_some((anchor.index, anchor.offset)) + }) + }) + } + + /// Return the preserved pre-width-resize scroll anchor. + #[must_use] + pub fn resize_scroll_anchor(&self) -> Option<(usize, usize)> { + self.remeasure_plan.and_then(|plan| { + plan.preserved_scroll_anchor.and_then(|anchor| { + (anchor.reason == LayoutRemeasureReason::Resize) + .then_some((anchor.index, anchor.offset)) + }) + }) + } + + /// Derive the priority window from the preserved scroll anchor using current estimates. + #[must_use] + pub fn remeasure_anchor_window(&self, viewport_height: usize) -> Option<(usize, usize)> { + let plan = self.remeasure_plan?; + if self.message_heights.is_empty() { + return None; + } + let start = plan.scroll_anchor_index.min(self.message_heights.len().saturating_sub(1)); + let mut end = start; + let needed_rows = plan.scroll_anchor_offset.saturating_add(viewport_height.max(1)); + let mut covered_rows = self.message_height(start); + while end + 1 < self.message_heights.len() && covered_rows < needed_rows { + end += 1; + covered_rows = covered_rows.saturating_add(self.message_height(end)); + } + Some((start, end)) + } + + /// Restore the absolute scroll position from a preserved message-local anchor. + #[allow(clippy::cast_precision_loss)] + pub fn restore_scroll_anchor(&mut self, anchor_index: usize, anchor_offset: usize) { + if self.auto_scroll || self.message_heights.is_empty() { + return; + } + let anchor_index = anchor_index.min(self.message_heights.len().saturating_sub(1)); + let anchor_height = self.message_height(anchor_index); + let clamped_offset = + if anchor_height == 0 { 0 } else { anchor_offset.min(anchor_height.saturating_sub(1)) }; + let scroll = self.cumulative_height_before(anchor_index).saturating_add(clamped_offset); + self.scroll_target = scroll; + self.scroll_pos = scroll as f32; + self.scroll_offset = scroll; + } + + /// Mark prefix sums dirty from `idx` onward. + pub fn mark_prefix_sums_dirty_from(&mut self, idx: usize) { + self.prefix_dirty_from = Some(self.prefix_dirty_from.map_or(idx, |oldest| oldest.min(idx))); + self.prefix_sums_width = 0; + } + + /// Finalize queued remeasurement when all messages are current again. + pub fn finalize_remeasure_if_clean(&mut self) { + if self.has_stale_message_heights() { + return; + } + self.message_heights_width = self.width; + self.measured_message_widths.fill(self.width); + self.priority_remeasure.clear(); + self.remeasure_plan = None; + } + + /// Mark all message heights exact at the current width. + /// + /// This remains available for tests that seed viewport state directly. + pub fn mark_heights_valid(&mut self) { + self.stale_message_heights.fill(false); + self.finalize_remeasure_if_clean(); + } + + /// Compatibility helper for tests and metrics. + #[must_use] + pub fn resize_remeasure_active(&self) -> bool { + self.remeasure_active() + } + + /// Compatibility helper for tests that seed and advance the queued plan directly. + pub fn ensure_resize_remeasure_anchor( + &mut self, + visible_start: usize, + visible_end: usize, + message_count: usize, + ) { + self.ensure_remeasure_anchor(visible_start, visible_end, message_count); + } + + /// Compatibility helper for tests that seed and advance the queued plan directly. + pub fn next_resize_remeasure_index(&mut self, message_count: usize) -> Option { + self.next_remeasure_index(message_count) + } + + // --- Prefix sums --- + + /// Rebuild prefix sums from `message_heights`, starting from the oldest dirty index. + pub fn rebuild_prefix_sums(&mut self) { + let n = self.message_heights.len(); + if n == 0 { + self.height_prefix_sums.clear(); + self.prefix_dirty_from = None; + self.prefix_sums_width = self.width; + return; + } + if self.height_prefix_sums.len() != n { + self.height_prefix_sums.resize(n, 0); + self.prefix_dirty_from = Some(0); + } + + let Some(start) = self.prefix_dirty_from else { + if self.prefix_sums_width == self.width { + return; + } + self.prefix_dirty_from = Some(0); + return self.rebuild_prefix_sums(); + }; + + let start = start.min(n.saturating_sub(1)); + let mut acc = if start == 0 { 0 } else { self.height_prefix_sums[start - 1] }; + for idx in start..n { + acc = acc.saturating_add(self.message_heights[idx]); + self.height_prefix_sums[idx] = acc; + } + self.prefix_dirty_from = None; + self.prefix_sums_width = self.width; + } + + /// Total height of all messages (O(1) via prefix sums). + #[must_use] + pub fn total_message_height(&self) -> usize { + self.height_prefix_sums.last().copied().unwrap_or(0) + } + + /// Cumulative height of messages `0..idx` (O(1) via prefix sums). + #[must_use] + pub fn cumulative_height_before(&self, idx: usize) -> usize { + if idx == 0 { 0 } else { self.height_prefix_sums.get(idx - 1).copied().unwrap_or(0) } + } + + /// Binary search for the first message whose cumulative range overlaps `scroll_offset`. + #[must_use] + pub fn find_first_visible(&self, scroll_offset: usize) -> usize { + if self.height_prefix_sums.is_empty() { + return 0; + } + self.height_prefix_sums + .partition_point(|&h| h <= scroll_offset) + .min(self.message_heights.len().saturating_sub(1)) + } + + /// Binary search for the last message whose cumulative range overlaps the viewport. + #[must_use] + pub fn find_last_visible(&self, scroll_offset: usize, viewport_height: usize) -> usize { + if self.height_prefix_sums.is_empty() { + return 0; + } + let visible_end = scroll_offset.saturating_add(viewport_height); + self.height_prefix_sums + .partition_point(|&h| h < visible_end) + .min(self.message_heights.len().saturating_sub(1)) + } + + /// Derive the current visible window from the latest estimated prefix sums. + #[must_use] + pub fn current_visible_window(&self, viewport_height: usize) -> Option<(usize, usize)> { + if self.message_heights.is_empty() { + return None; + } + if self.height_prefix_sums.is_empty() { + return Some((0, 0)); + } + let start = self.find_first_visible(self.scroll_offset); + let end = self.find_last_visible(self.scroll_offset, viewport_height.max(1)); + Some((start.min(end), end.max(start))) + } + + fn current_scroll_anchor(&self) -> (usize, usize) { + if self.message_heights.is_empty() { + return (0, 0); + } + let first_visible = self.find_first_visible_in_estimates(self.scroll_offset); + let offset_in_message = self + .scroll_offset + .saturating_sub(self.cumulative_height_before_in_estimates(first_visible)); + (first_visible, offset_in_message) + } + + fn find_first_visible_in_estimates(&self, scroll_offset: usize) -> usize { + let mut acc = 0usize; + for (idx, &height) in self.message_heights.iter().enumerate() { + acc = acc.saturating_add(height); + if acc > scroll_offset { + return idx; + } + } + if acc == 0 { 0 } else { self.message_heights.len().saturating_sub(1) } + } + + fn cumulative_height_before_in_estimates(&self, idx: usize) -> usize { + self.message_heights.iter().take(idx).copied().sum() + } + + fn prefix_is_exact_through(&self, idx: usize) -> bool { + if self.message_heights.is_empty() { + return false; + } + let end = idx.min(self.message_heights.len().saturating_sub(1)); + if self.message_heights_width == self.width { + return !self.stale_message_heights.iter().take(end + 1).any(|stale| *stale); + } + !(0..=end).any(|message_idx| { + self.stale_message_heights.get(message_idx).copied().unwrap_or(true) + || self.measured_message_widths.get(message_idx).copied().unwrap_or(0) != self.width + }) + } + + // --- Scroll --- + + /// Scroll up by `lines`. Disables auto-scroll. + #[allow(clippy::cast_precision_loss)] + pub fn scroll_up(&mut self, lines: usize) { + self.scroll_target = self.scroll_target.saturating_sub(lines); + self.auto_scroll = false; + self.scroll_pos = self.scroll_target as f32; + self.scroll_offset = self.scroll_target; + } + + /// Scroll down by `lines`. Auto-scroll re-engagement handled by render. + #[allow(clippy::cast_precision_loss)] + pub fn scroll_down(&mut self, lines: usize) { + self.scroll_target = self.scroll_target.saturating_add(lines); + self.scroll_pos = self.scroll_target as f32; + self.scroll_offset = self.scroll_target; + } + + /// Re-engage auto-scroll (stick to bottom). + pub fn engage_auto_scroll(&mut self) { + self.auto_scroll = true; + } +} + +impl Default for ChatViewport { + fn default() -> Self { + Self::new() + } +} diff --git a/claude-code-rust/src/app/subagent.rs b/claude-code-rust/src/app/subagent.rs new file mode 100644 index 0000000..41d5bbe --- /dev/null +++ b/claude-code-rust/src/app/subagent.rs @@ -0,0 +1,353 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::{App, FocusTarget, dialog::DialogState}; + +/// Maximum candidates shown in the dropdown. +pub const MAX_VISIBLE: usize = 8; +const MAX_CANDIDATES: usize = 50; + +#[derive(Debug, Clone)] +pub struct SubagentCandidate { + pub name: String, + pub description: String, + pub model: Option, +} + +#[derive(Debug, Clone)] +pub struct SubagentState { + /// Character position where the `&` token starts. + pub trigger_row: usize, + pub trigger_col: usize, + /// Current query text after `&`. + pub query: String, + /// Filtered subagent candidates. + pub candidates: Vec, + /// Shared autocomplete dialog navigation state. + pub dialog: DialogState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SubagentDetection { + trigger_row: usize, + trigger_col: usize, + query: String, +} + +fn is_subagent_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' +} + +fn detect_subagent_at_cursor( + lines: &[String], + cursor_row: usize, + cursor_col: usize, +) -> Option { + let line = lines.get(cursor_row)?; + let chars: Vec = line.chars().collect(); + if cursor_col > chars.len() { + return None; + } + + let mut token_start = cursor_col; + while token_start > 0 && !chars[token_start - 1].is_whitespace() { + token_start -= 1; + } + if token_start >= chars.len() || chars[token_start] != '&' { + return None; + } + + // Reject shell-style logical and operators (`&&`). + if chars.get(token_start + 1).copied() == Some('&') { + return None; + } + + let token_end = + (token_start + 1..chars.len()).find(|&i| chars[i].is_whitespace()).unwrap_or(chars.len()); + if cursor_col <= token_start || cursor_col > token_end { + return None; + } + + // Eager activation: allow a bare `&` only when it is at token end (`... &` with no + // trailing chars). This avoids triggering on spacing/operator patterns like `& `. + if cursor_col == token_start + 1 { + if token_end == chars.len() { + return Some(SubagentDetection { + trigger_row: cursor_row, + trigger_col: token_start, + query: String::new(), + }); + } + return None; + } + + // First character after '&' must be alphabetic to start a valid subagent token. + if !chars[token_start + 1].is_ascii_alphabetic() { + return None; + } + + if chars[token_start + 1..token_end].iter().any(|ch| !is_subagent_char(*ch)) { + return None; + } + let query: String = chars[token_start + 1..cursor_col].iter().collect(); + + Some(SubagentDetection { trigger_row: cursor_row, trigger_col: token_start, query }) +} + +fn candidate_matches(candidate: &SubagentCandidate, query_lower: &str) -> bool { + candidate.name.to_lowercase().contains(query_lower) + || candidate.description.to_lowercase().contains(query_lower) + || candidate.model.as_ref().is_some_and(|model| model.to_lowercase().contains(query_lower)) +} + +fn filter_candidates( + candidates: &[crate::agent::model::AvailableAgent], + query: &str, +) -> Vec { + let query_lower = query.to_lowercase(); + candidates + .iter() + .filter(|agent| !agent.name.trim().is_empty()) + .map(|agent| SubagentCandidate { + name: agent.name.clone(), + description: agent.description.clone(), + model: agent.model.clone(), + }) + .filter(|candidate| candidate_matches(candidate, &query_lower)) + .take(MAX_CANDIDATES) + .collect() +} + +fn build_subagent_state(app: &App) -> Option { + let detection = detect_subagent_at_cursor( + app.input.lines(), + app.input.cursor_row(), + app.input.cursor_col(), + )?; + let candidates = filter_candidates(&app.available_agents, &detection.query); + if candidates.is_empty() { + return None; + } + Some(SubagentState { + trigger_row: detection.trigger_row, + trigger_col: detection.trigger_col, + query: detection.query, + candidates, + dialog: DialogState::default(), + }) +} + +pub fn activate(app: &mut App) { + let Some(state) = build_subagent_state(app) else { + return; + }; + app.subagent = Some(state); + app.mention = None; + app.slash = None; + app.claim_focus_target(FocusTarget::Mention); +} + +pub fn update_query(app: &mut App) { + let Some(next_state) = build_subagent_state(app) else { + deactivate(app); + return; + }; + + if let Some(ref mut subagent) = app.subagent { + subagent.trigger_row = next_state.trigger_row; + subagent.trigger_col = next_state.trigger_col; + subagent.query = next_state.query; + subagent.candidates = next_state.candidates; + subagent.dialog.clamp(subagent.candidates.len(), MAX_VISIBLE); + } else { + app.subagent = Some(next_state); + app.claim_focus_target(FocusTarget::Mention); + } +} + +pub fn sync_with_cursor(app: &mut App) { + match (build_subagent_state(app), app.subagent.is_some()) { + (Some(_), true) => update_query(app), + (Some(_), false) => activate(app), + (None, true) => deactivate(app), + (None, false) => {} + } +} + +pub fn deactivate(app: &mut App) { + app.subagent = None; + if app.mention.is_none() && app.slash.is_none() { + app.release_focus_target(FocusTarget::Mention); + } +} + +pub fn move_up(app: &mut App) { + if let Some(ref mut subagent) = app.subagent { + subagent.dialog.move_up(subagent.candidates.len(), MAX_VISIBLE); + } +} + +pub fn move_down(app: &mut App) { + if let Some(ref mut subagent) = app.subagent { + subagent.dialog.move_down(subagent.candidates.len(), MAX_VISIBLE); + } +} + +pub fn confirm_selection(app: &mut App) { + let Some(subagent) = app.subagent.take() else { + return; + }; + + let Some(candidate) = subagent.candidates.get(subagent.dialog.selected) else { + if app.mention.is_none() && app.slash.is_none() { + app.release_focus_target(FocusTarget::Mention); + } + return; + }; + + let mut lines = app.input.lines().to_vec(); + let Some(line) = lines.get(subagent.trigger_row) else { + if app.mention.is_none() && app.slash.is_none() { + app.release_focus_target(FocusTarget::Mention); + } + return; + }; + + let chars: Vec = line.chars().collect(); + if subagent.trigger_col >= chars.len() || chars[subagent.trigger_col] != '&' { + if app.mention.is_none() && app.slash.is_none() { + app.release_focus_target(FocusTarget::Mention); + } + return; + } + + let token_end = (subagent.trigger_col + 1..chars.len()) + .find(|&i| chars[i].is_whitespace()) + .unwrap_or(chars.len()); + let before: String = chars[..subagent.trigger_col].iter().collect(); + let after: String = chars[token_end..].iter().collect(); + let replacement = if after.is_empty() { + format!("&{} ", candidate.name) + } else { + format!("&{}", candidate.name) + }; + let new_line = format!("{before}{replacement}{after}"); + let new_cursor_col = subagent.trigger_col + replacement.chars().count(); + let new_line_len = new_line.chars().count(); + lines[subagent.trigger_row] = new_line; + app.input.replace_lines_and_cursor( + lines, + subagent.trigger_row, + new_cursor_col.min(new_line_len), + ); + + sync_with_cursor(app); + if app.mention.is_none() && app.slash.is_none() && app.subagent.is_none() { + app.release_focus_target(FocusTarget::Mention); + } +} + +pub fn find_subagent_spans(text: &str) -> Vec<(usize, usize, String)> { + let mut spans = Vec::new(); + let chars: Vec = text.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] != '&' { + i += 1; + continue; + } + if i > 0 && !chars[i - 1].is_whitespace() { + i += 1; + continue; + } + if i + 1 >= chars.len() || !chars[i + 1].is_ascii_alphabetic() { + i += 1; + continue; + } + if chars[i + 1] == '&' { + i += 1; + continue; + } + + let mut end = i + 1; + while end < chars.len() && !chars[end].is_whitespace() { + if !is_subagent_char(chars[end]) { + break; + } + end += 1; + } + if end <= i + 1 { + i += 1; + continue; + } + if end < chars.len() && !chars[end].is_whitespace() { + i = end + 1; + continue; + } + + let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum(); + let byte_end: usize = chars[..end].iter().map(|c| c.len_utf8()).sum(); + let name: String = chars[i + 1..end].iter().collect(); + spans.push((byte_start, byte_end, name)); + i = end; + } + + spans +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + + #[test] + fn detect_subagent_requires_alpha_after_ampersand() { + let lines = vec!["& review".to_owned()]; + assert!(detect_subagent_at_cursor(&lines, 0, 1).is_none()); + } + + #[test] + fn detect_subagent_rejects_double_ampersand() { + let lines = vec!["cmd && build".to_owned()]; + assert!(detect_subagent_at_cursor(&lines, 0, 6).is_none()); + } + + #[test] + fn sync_with_cursor_activates_when_subagent_token_is_valid() { + let mut app = App::test_default(); + app.available_agents = vec![ + crate::agent::model::AvailableAgent::new("reviewer", "Review code"), + crate::agent::model::AvailableAgent::new("explore", "Explore codebase"), + ]; + app.input.set_text("&re"); + let _ = app.input.set_cursor_col(3); + + sync_with_cursor(&mut app); + + let state = app.subagent.as_ref().expect("subagent state should be active"); + assert_eq!(state.query, "re"); + assert!(!state.candidates.is_empty()); + } + + #[test] + fn sync_with_cursor_activates_on_bare_ampersand_at_line_end() { + let mut app = App::test_default(); + app.available_agents = + vec![crate::agent::model::AvailableAgent::new("reviewer", "Review code")]; + app.input.set_text("&"); + + sync_with_cursor(&mut app); + + let state = app.subagent.as_ref().expect("subagent state should be active"); + assert_eq!(state.query, ""); + assert!(!state.candidates.is_empty()); + } + + #[test] + fn find_subagent_spans_ignores_double_ampersand() { + let spans = find_subagent_spans("run && wait &reviewer"); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].2, "reviewer"); + } +} diff --git a/claude-code-rust/src/app/terminal.rs b/claude-code-rust/src/app/terminal.rs new file mode 100644 index 0000000..e776a74 --- /dev/null +++ b/claude-code-rust/src/app/terminal.rs @@ -0,0 +1,212 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::{App, MessageBlock, TerminalSnapshotMode, ToolCallInfo}; + +enum TerminalUpdatePayload { + Append { bytes: Vec, current_len: usize }, + Replace { bytes: Vec, current_len: usize }, +} + +fn apply_terminal_payload(tc: &mut ToolCallInfo, payload: TerminalUpdatePayload) -> bool { + match payload { + TerminalUpdatePayload::Append { bytes, current_len } => { + if bytes.is_empty() { + return false; + } + let delta = String::from_utf8_lossy(&bytes); + crate::perf::mark_with("terminal_delta_bytes", "bytes", bytes.len()); + let output = tc.terminal_output.get_or_insert_with(String::new); + output.push_str(&delta); + tc.terminal_bytes_seen = current_len; + tc.terminal_output_len = current_len; + tc.terminal_snapshot_mode = TerminalSnapshotMode::AppendOnly; + true + } + TerminalUpdatePayload::Replace { bytes, current_len } => { + crate::perf::mark("terminal_full_snapshot_fallbacks"); + let snapshot = String::from_utf8_lossy(&bytes).to_string(); + let changed = tc.terminal_output.as_deref() != Some(snapshot.as_str()); + if changed { + tc.terminal_output = Some(snapshot); + } + tc.terminal_bytes_seen = current_len; + tc.terminal_output_len = current_len; + tc.terminal_snapshot_mode = TerminalSnapshotMode::AppendOnly; + changed + } + } +} + +/// Snapshot terminal output buffers into `ToolCallInfo` for rendering. +/// Called each frame so in-progress Execute tool calls show live output. +/// +/// Uses append-only deltas when possible, with full-snapshot fallback when +/// invariants are broken (truncate/reset/replace mode). +pub(super) fn update_terminal_outputs(app: &mut App) -> bool { + let _t = app.perf.as_ref().map(|p| p.start("terminal::update")); + let terminals = app.terminals.borrow(); + if terminals.is_empty() { + return false; + } + + let mut changed = false; + let mut dirty_messages = Vec::new(); + let mut dirty_slots = Vec::new(); + + // Use the indexed terminal tool calls instead of scanning all messages/blocks. + for terminal_ref in &app.terminal_tool_calls { + let Some(terminal) = terminals.get(terminal_ref.terminal_id.as_str()) else { + continue; + }; + let Some(MessageBlock::ToolCall(tc)) = app + .messages + .get_mut(terminal_ref.msg_idx) + .and_then(|m| m.blocks.get_mut(terminal_ref.block_idx)) + else { + continue; + }; + let tc = tc.as_mut(); + if !matches!( + tc.status, + crate::agent::model::ToolCallStatus::Pending + | crate::agent::model::ToolCallStatus::InProgress + ) { + continue; + } + + // Copy only the required bytes under lock, then decode outside the + // critical section to avoid blocking output writers. + let payload = { + let Ok(buf) = terminal.output_buffer.lock() else { + continue; + }; + let current_len = buf.len(); + let force_replace = + matches!(tc.terminal_snapshot_mode, TerminalSnapshotMode::ReplaceSnapshot); + if !force_replace && current_len == tc.terminal_bytes_seen { + continue; + } + if !force_replace && current_len > tc.terminal_bytes_seen { + TerminalUpdatePayload::Append { + bytes: buf[tc.terminal_bytes_seen..].to_vec(), + current_len, + } + } else { + TerminalUpdatePayload::Replace { bytes: buf.clone(), current_len } + } + }; + if apply_terminal_payload(tc, payload) { + tc.mark_tool_call_layout_dirty(); + dirty_slots.push((terminal_ref.msg_idx, terminal_ref.block_idx)); + if dirty_messages.last().copied() != Some(terminal_ref.msg_idx) { + dirty_messages.push(terminal_ref.msg_idx); + } + changed = true; + } + } + + drop(terminals); + + for (mi, bi) in dirty_slots { + app.sync_render_cache_slot(mi, bi); + } + for mi in dirty_messages.iter().copied() { + app.recompute_message_retained_bytes(mi); + } + app.invalidate_message_set(dirty_messages.iter().copied()); + + changed +} + +#[cfg(test)] +mod tests { + use super::update_terminal_outputs; + use crate::agent::events::TerminalProcess; + use crate::agent::model; + use crate::app::{ + App, BlockCache, ChatMessage, MessageBlock, MessageRole, TerminalSnapshotMode, TextBlock, + ToolCallInfo, + }; + use std::sync::{Arc, Mutex}; + + fn bash_tool_message(id: &str, terminal_id: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(ToolCallInfo { + id: id.to_owned(), + title: format!("tool {id}"), + sdk_tool_name: "Bash".to_owned(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status: model::ToolCallStatus::InProgress, + content: Vec::new(), + hidden: false, + terminal_id: Some(terminal_id.to_owned()), + terminal_command: Some(format!("echo {id}")), + terminal_output: None, + terminal_output_len: 0, + terminal_bytes_seen: 0, + terminal_snapshot_mode: TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + }))], + usage: None, + } + } + + fn user_message(text: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::User, + blocks: vec![MessageBlock::Text(TextBlock::from_complete(text))], + usage: None, + } + } + + #[test] + fn terminal_updates_invalidate_all_dirty_messages() { + let mut app = App::test_default(); + app.messages.push(bash_tool_message("bash-1", "term-1")); + app.messages.push(user_message("gap")); + app.messages.push(bash_tool_message("bash-2", "term-2")); + app.index_tool_call("bash-1".to_owned(), 0, 0); + app.index_tool_call("bash-2".to_owned(), 2, 0); + app.sync_terminal_tool_call("term-1".to_owned(), 0, 0); + app.sync_terminal_tool_call("term-2".to_owned(), 2, 0); + app.terminals.borrow_mut().insert( + "term-1".to_owned(), + TerminalProcess { + child: None, + output_buffer: Arc::new(Mutex::new(b"alpha\n".to_vec())), + command: "echo alpha".to_owned(), + }, + ); + app.terminals.borrow_mut().insert( + "term-2".to_owned(), + TerminalProcess { + child: None, + output_buffer: Arc::new(Mutex::new(b"beta\n".to_vec())), + command: "echo beta".to_owned(), + }, + ); + + let _ = app.viewport.on_frame(80, 24); + app.viewport.sync_message_count(3); + app.viewport.mark_heights_valid(); + app.viewport.rebuild_prefix_sums(); + + assert!(update_terminal_outputs(&mut app)); + assert!(!app.viewport.message_height_is_current(0)); + assert!(app.viewport.message_height_is_current(1)); + assert!(!app.viewport.message_height_is_current(2)); + assert_eq!(app.viewport.oldest_stale_index(), Some(0)); + } +} diff --git a/claude-code-rust/src/app/todos.rs b/claude-code-rust/src/app/todos.rs new file mode 100644 index 0000000..c856095 --- /dev/null +++ b/claude-code-rust/src/app/todos.rs @@ -0,0 +1,441 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::{App, FocusTarget, TodoItem, TodoStatus}; +use crate::agent::model; + +/// Parse a `TodoWrite` `raw_input` JSON value into a `Vec`. +/// Expected shape: `{"todos": [{"content": "...", "status": "...", "activeForm": "..."}]}` +#[cfg(test)] +pub(super) fn parse_todos(raw_input: &serde_json::Value) -> Vec { + parse_todos_if_present(raw_input).unwrap_or_default() +} + +/// Parse todos only when a concrete `todos` array is present in `raw_input`. +/// Returns `None` for transient/incomplete payloads (missing or non-array `todos`). +pub(super) fn parse_todos_if_present(raw_input: &serde_json::Value) -> Option> { + let arr = raw_input.get("todos")?.as_array()?; + Some( + arr.iter() + .filter_map(|item| { + let content = item.get("content")?.as_str()?.to_owned(); + let status_str = item.get("status")?.as_str()?; + let active_form = + item.get("activeForm").and_then(|v| v.as_str()).unwrap_or("").to_owned(); + let status = match status_str { + "in_progress" => TodoStatus::InProgress, + "completed" => TodoStatus::Completed, + _ => TodoStatus::Pending, + }; + Some(TodoItem { content, status, active_form }) + }) + .collect(), + ) +} + +pub(super) fn set_todos(app: &mut App, todos: Vec) { + app.cached_todo_compact = None; + if todos.is_empty() { + app.todos.clear(); + app.show_todo_panel = false; + app.todo_scroll = 0; + app.todo_selected = 0; + app.release_focus_target(FocusTarget::TodoList); + return; + } + + let all_done = todos.iter().all(|t| t.status == TodoStatus::Completed); + if all_done { + app.todos.clear(); + app.show_todo_panel = false; + app.todo_scroll = 0; + app.todo_selected = 0; + app.release_focus_target(FocusTarget::TodoList); + } else { + app.todos = todos; + if app.todo_selected >= app.todos.len() { + app.todo_selected = app.todos.len().saturating_sub(1); + } + if !app.show_todo_panel { + app.release_focus_target(FocusTarget::TodoList); + } + } +} + +/// Convert bridge plan entries into the local todo list. +pub(super) fn apply_plan_todos(app: &mut App, plan: &model::Plan) { + app.cached_todo_compact = None; + let mut todos = Vec::with_capacity(plan.entries.len()); + for entry in &plan.entries { + let status_str = format!("{:?}", entry.status); + let status = match status_str.as_str() { + "InProgress" => TodoStatus::InProgress, + "Completed" => TodoStatus::Completed, + _ => TodoStatus::Pending, + }; + todos.push(TodoItem { content: entry.content.clone(), status, active_form: String::new() }); + } + set_todos(app, todos); +} + +#[cfg(test)] +mod tests { + // ===== + // TESTS: 32 + // ===== + + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + // parse_todos + + #[test] + fn parse_valid_all_statuses() { + let input = json!({ + "todos": [ + {"content": "Task A", "status": "pending", "activeForm": "Doing A"}, + {"content": "Task B", "status": "in_progress", "activeForm": "Doing B"}, + {"content": "Task C", "status": "completed", "activeForm": "Done C"}, + ] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 3); + assert_eq!(todos[0].content, "Task A"); + assert_eq!(todos[0].status, TodoStatus::Pending); + assert_eq!(todos[0].active_form, "Doing A"); + assert_eq!(todos[1].status, TodoStatus::InProgress); + assert_eq!(todos[2].status, TodoStatus::Completed); + } + + #[test] + fn parse_missing_active_form_defaults_empty() { + let input = json!({ + "todos": [{"content": "Task", "status": "pending"}] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].active_form, ""); + } + + #[test] + fn parse_empty_array() { + let input = json!({"todos": []}); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_if_present_missing_todos_returns_none() { + let input = json!({"other": 1}); + let todos = parse_todos_if_present(&input); + assert!(todos.is_none()); + } + + #[test] + fn parse_if_present_non_array_todos_returns_none() { + let input = json!({"todos": {"not": "array"}}); + let todos = parse_todos_if_present(&input); + assert!(todos.is_none()); + } + + #[test] + fn parse_if_present_empty_array_returns_some_empty_vec() { + let input = json!({"todos": []}); + let todos = parse_todos_if_present(&input); + assert!(matches!(todos, Some(v) if v.is_empty())); + } + + // parse_todos + + #[test] + fn parse_missing_todos_key() { + let input = json!({"something_else": 42}); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_todos_not_array() { + let input = json!({"todos": "not an array"}); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_missing_content_skips_item() { + let input = json!({ + "todos": [ + {"status": "pending", "activeForm": "Missing content"}, + {"content": "Valid", "status": "pending"}, + ] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].content, "Valid"); + } + + #[test] + fn parse_missing_status_skips_item() { + let input = json!({ + "todos": [ + {"content": "No status"}, + {"content": "Valid", "status": "pending"}, + ] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 1); + } + + #[test] + fn parse_unknown_status_maps_to_pending() { + let input = json!({ + "todos": [{"content": "Task", "status": "banana"}] + }); + let todos = parse_todos(&input); + assert_eq!(todos[0].status, TodoStatus::Pending); + } + + // parse_todos + + #[test] + fn parse_null_input() { + let input = serde_json::Value::Null; + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_content_is_number_not_string() { + let input = json!({ + "todos": [{"content": 42, "status": "pending"}] + }); + let todos = parse_todos(&input); + assert!(todos.is_empty()); // content.as_str() returns None for number + } + + #[test] + fn parse_large_todo_list() { + let items: Vec = (0..100) + .map(|i| json!({"content": format!("Task {i}"), "status": "pending"})) + .collect(); + let input = json!({"todos": items}); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 100); + } + + #[test] + fn parse_mixed_valid_and_invalid() { + let input = json!({ + "todos": [ + {"content": "Good", "status": "completed"}, + {}, + {"content": "Also good", "status": "in_progress"}, + {"status": "pending"}, + null, + ] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 2); + assert_eq!(todos[0].content, "Good"); + assert_eq!(todos[1].content, "Also good"); + } + + // weird JSON inputs + + #[test] + fn parse_unicode_content_and_active_form() { + let input = json!({ + "todos": [{"content": "\u{1F680} Deploy to prod", "status": "in_progress", "activeForm": "\u{1F525} Deploying"}] + }); + let todos = parse_todos(&input); + assert_eq!(todos[0].content, "\u{1F680} Deploy to prod"); + assert_eq!(todos[0].active_form, "\u{1F525} Deploying"); + } + + #[test] + fn parse_empty_string_content() { + let input = json!({ + "todos": [{"content": "", "status": "pending"}] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].content, ""); + } + + #[test] + fn parse_empty_string_status() { + let input = json!({ + "todos": [{"content": "Task", "status": ""}] + }); + let todos = parse_todos(&input); + // Empty string doesn't match "in_progress" or "completed" -> Pending + assert_eq!(todos[0].status, TodoStatus::Pending); + } + + #[test] + fn parse_status_is_boolean() { + let input = json!({ + "todos": [{"content": "Task", "status": true}] + }); + let todos = parse_todos(&input); + assert!(todos.is_empty()); // status.as_str() returns None for bool + } + + #[test] + fn parse_status_is_array() { + let input = json!({ + "todos": [{"content": "Task", "status": ["pending"]}] + }); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_status_is_nested_object() { + let input = json!({ + "todos": [{"content": "Task", "status": {"value": "pending"}}] + }); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_active_form_is_number() { + let input = json!({ + "todos": [{"content": "Task", "status": "pending", "activeForm": 42}] + }); + let todos = parse_todos(&input); + // activeForm.as_str() returns None -> unwrap_or("") -> "" + assert_eq!(todos[0].active_form, ""); + } + + #[test] + fn parse_extra_keys_ignored() { + let input = json!({ + "todos": [{ + "content": "Task", + "status": "pending", + "activeForm": "Doing", + "extraKey": "should be ignored", + "priority": 1, + "nested": {"a": "b"} + }] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].content, "Task"); + } + + #[test] + fn parse_todos_key_is_null() { + let input = json!({"todos": null}); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_todos_key_is_object() { + let input = json!({"todos": {"not": "an array"}}); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_todos_key_is_number() { + let input = json!({"todos": 42}); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_very_long_content() { + let long_content = "A".repeat(10_000); + let input = json!({ + "todos": [{"content": long_content, "status": "pending"}] + }); + let todos = parse_todos(&input); + assert_eq!(todos[0].content.len(), 10_000); + } + + #[test] + fn parse_content_with_newlines_and_special_chars() { + let input = json!({ + "todos": [{"content": "line1\nline2\ttab\r\nwindows", "status": "pending"}] + }); + let todos = parse_todos(&input); + assert!(todos[0].content.contains('\n')); + assert!(todos[0].content.contains('\t')); + } + + #[test] + fn parse_deeply_nested_json_value() { + // The input itself is a deeply nested object -- todos key still works + let input = json!({ + "metadata": {"nested": {"deep": true}}, + "todos": [{"content": "Found it", "status": "completed"}], + "other": [1, 2, 3] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 1); + assert_eq!(todos[0].content, "Found it"); + } + + #[test] + fn parse_duplicate_items() { + let input = json!({ + "todos": [ + {"content": "Same", "status": "pending"}, + {"content": "Same", "status": "pending"}, + {"content": "Same", "status": "pending"}, + ] + }); + let todos = parse_todos(&input); + assert_eq!(todos.len(), 3); // duplicates are fine + } + + #[test] + fn parse_status_case_sensitive() { + // "In_Progress", "COMPLETED" should NOT match -> Pending + let input = json!({ + "todos": [ + {"content": "A", "status": "In_Progress"}, + {"content": "B", "status": "COMPLETED"}, + {"content": "C", "status": "Pending"}, + ] + }); + let todos = parse_todos(&input); + assert_eq!(todos[0].status, TodoStatus::Pending); + assert_eq!(todos[1].status, TodoStatus::Pending); + assert_eq!(todos[2].status, TodoStatus::Pending); // "Pending" != "pending" + } + + #[test] + fn parse_array_input_not_object() { + // Top-level is an array, not an object -- no "todos" key + let input = json!([{"content": "Task", "status": "pending"}]); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_string_input() { + let input = json!("just a string"); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_boolean_input() { + let input = json!(true); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } + + #[test] + fn parse_number_input() { + let input = json!(42); + let todos = parse_todos(&input); + assert!(todos.is_empty()); + } +} diff --git a/claude-code-rust/src/app/trust/mod.rs b/claude-code-rust/src/app/trust/mod.rs new file mode 100644 index 0000000..d02d712 --- /dev/null +++ b/claude-code-rust/src/app/trust/mod.rs @@ -0,0 +1,220 @@ +pub(crate) mod store; + +use super::App; +use super::view::{self, ActiveView}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TrustStatus { + #[default] + Trusted, + Untrusted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TrustSelection { + #[default] + Yes, + No, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TrustState { + pub status: TrustStatus, + pub selection: TrustSelection, + pub project_key: String, + pub last_error: Option, +} + +impl TrustState { + #[must_use] + pub fn is_trusted(&self) -> bool { + matches!(self.status, TrustStatus::Trusted) + } +} + +pub fn initialize(app: &mut App) { + let lookup = store::read_status( + &app.config.committed_preferences_document, + std::path::Path::new(&app.cwd_raw), + ); + app.trust.project_key = lookup.project_key; + app.trust.status = if lookup.trusted { TrustStatus::Trusted } else { TrustStatus::Untrusted }; + app.trust.selection = TrustSelection::Yes; + app.trust.last_error = app.config.preferences_path.is_none().then(|| { + app.config + .last_error + .clone() + .unwrap_or_else(|| "Trust preferences path is not available".to_owned()) + }); + app.startup_connection_requested = app.trust.is_trusted(); + if app.trust.is_trusted() { + view::set_active_view(app, ActiveView::Chat); + } else { + view::set_active_view(app, ActiveView::Trusted); + } +} + +pub fn handle_key(app: &mut App, key: KeyEvent) { + if is_ctrl_shortcut(key, 'q') || is_ctrl_shortcut(key, 'c') { + app.should_quit = true; + return; + } + + match (key.code, key.modifiers) { + (KeyCode::Up, KeyModifiers::NONE) => app.trust.selection = TrustSelection::Yes, + (KeyCode::Down, KeyModifiers::NONE) => app.trust.selection = TrustSelection::No, + (KeyCode::Enter, KeyModifiers::NONE) => activate_selection(app), + (KeyCode::Char('y' | 'Y'), KeyModifiers::NONE) => { + app.trust.selection = TrustSelection::Yes; + activate_selection(app); + } + (KeyCode::Esc | KeyCode::Char('n' | 'N'), KeyModifiers::NONE) => { + app.trust.selection = TrustSelection::No; + activate_selection(app); + } + _ => {} + } +} + +pub fn accept(app: &mut App) -> Result<(), String> { + let Some(path) = app.config.preferences_path.clone() else { + return Err("Trust preferences path is not available".to_owned()); + }; + + let mut next_document = app.config.committed_preferences_document.clone(); + app.trust.project_key = + store::set_trusted(&mut next_document, std::path::Path::new(&app.cwd_raw)); + crate::app::config::store::save(&path, &next_document)?; + + app.config.committed_preferences_document = next_document; + app.trust.status = TrustStatus::Trusted; + app.trust.last_error = None; + app.startup_connection_requested = true; + view::set_active_view(app, ActiveView::Chat); + Ok(()) +} + +pub fn decline(app: &mut App) { + app.should_quit = true; +} + +fn activate_selection(app: &mut App) { + match app.trust.selection { + TrustSelection::Yes => { + if let Err(err) = accept(app) { + app.trust.last_error = Some(err); + } + } + TrustSelection::No => decline(app), + } +} + +fn is_ctrl_shortcut(key: KeyEvent, ch: char) -> bool { + matches!(key.code, KeyCode::Char(candidate) if candidate == ch) + && key.modifiers == KeyModifiers::CONTROL +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn initialize_routes_untrusted_projects_to_trusted_view() { + let mut app = App::test_default(); + app.cwd_raw = if cfg!(windows) { + r"C:\work\project".to_owned() + } else { + "/home/user/work/project".to_owned() + }; + app.config.preferences_path = Some(std::path::PathBuf::from("prefs.json")); + app.config.committed_preferences_document = json!({ + "projects": {} + }); + + initialize(&mut app); + + assert_eq!(app.active_view, ActiveView::Trusted); + assert!(!app.is_project_trusted()); + assert_eq!(app.trust.selection, TrustSelection::Yes); + assert!(!app.startup_connection_requested); + } + + #[test] + fn initialize_allows_trusted_projects_into_chat() { + let project_path = + if cfg!(windows) { "C:/work/project" } else { "/home/user/work/project" }; + + let mut app = App::test_default(); + app.cwd_raw = project_path.to_owned(); + app.config.preferences_path = Some(std::path::PathBuf::from("prefs.json")); + let mut prefs = json!({ "projects": {} }); + prefs["projects"][project_path] = json!({ + "hasTrustDialogAccepted": true + }); + app.config.committed_preferences_document = prefs; + + initialize(&mut app); + + assert_eq!(app.active_view, ActiveView::Chat); + assert!(app.is_project_trusted()); + assert!(app.startup_connection_requested); + } + + #[test] + fn accept_persists_trust_and_switches_to_chat() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude.json"); + std::fs::write(&path, "{\n \"projects\": {}\n}\n").expect("write"); + + let mut app = App::test_default(); + app.active_view = ActiveView::Trusted; + app.cwd_raw = dir.path().join("project").to_string_lossy().to_string(); + app.config.preferences_path = Some(path.clone()); + app.trust.status = TrustStatus::Untrusted; + app.trust.project_key = store::normalize_project_key(std::path::Path::new(&app.cwd_raw)); + + accept(&mut app).expect("accept"); + + let raw = std::fs::read_to_string(path).expect("read"); + assert!(raw.contains("\"hasTrustDialogAccepted\": true")); + assert_eq!(app.active_view, ActiveView::Chat); + assert!(app.is_project_trusted()); + assert!(app.startup_connection_requested); + } + + #[test] + fn handle_key_declines_with_n() { + let mut app = App::test_default(); + app.active_view = ActiveView::Trusted; + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + + assert!(app.should_quit); + } + + #[test] + fn handle_key_moves_selection_with_up_and_down() { + let mut app = App::test_default(); + app.active_view = ActiveView::Trusted; + app.trust.selection = TrustSelection::Yes; + + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(app.trust.selection, TrustSelection::No); + + handle_key(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(app.trust.selection, TrustSelection::Yes); + } + + #[test] + fn handle_key_enter_declines_when_no_is_selected() { + let mut app = App::test_default(); + app.active_view = ActiveView::Trusted; + app.trust.selection = TrustSelection::No; + + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(app.should_quit); + } +} diff --git a/claude-code-rust/src/app/trust/store.rs b/claude-code-rust/src/app/trust/store.rs new file mode 100644 index 0000000..0884b42 --- /dev/null +++ b/claude-code-rust/src/app/trust/store.rs @@ -0,0 +1,470 @@ +use serde_json::{Map, Value}; +use std::path::{Path, PathBuf}; + +const TRUST_FIELD: &str = "hasTrustDialogAccepted"; +const PROJECTS_FIELD: &str = "projects"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustLookup { + pub project_key: String, + pub trusted: bool, +} + +pub fn read_status(document: &Value, project_root: &Path) -> TrustLookup { + let project_key = normalize_project_key(project_root); + let projects = document.get(PROJECTS_FIELD).and_then(Value::as_object); + let trusted = projects.is_some_and(|projects| { + projects.iter().any(|(key, value)| { + project_keys_match(key, &project_key) && trust_value(value).unwrap_or(false) + }) + }); + + TrustLookup { project_key, trusted } +} + +pub fn set_trusted(document: &mut Value, project_root: &Path) -> String { + let project_key = normalize_project_key(project_root); + let root = ensure_object_mut(document); + let projects = + root.entry(PROJECTS_FIELD.to_owned()).or_insert_with(|| Value::Object(Map::new())); + if !projects.is_object() { + *projects = Value::Object(Map::new()); + } + + let Value::Object(projects) = projects else { + unreachable!("projects must be an object after normalization"); + }; + + let matching_keys = projects + .keys() + .filter(|key| project_keys_match(key, &project_key)) + .cloned() + .collect::>(); + + if matching_keys.is_empty() { + let entry = + projects.entry(project_key.clone()).or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + match entry { + Value::Object(project) => { + project.insert(TRUST_FIELD.to_owned(), Value::Bool(true)); + } + _ => unreachable!("project entry must be an object after normalization"), + } + return project_key; + } + + for key in matching_keys { + let entry = projects.entry(key).or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + match entry { + Value::Object(project) => { + project.insert(TRUST_FIELD.to_owned(), Value::Bool(true)); + } + _ => unreachable!("project entry must be an object after normalization"), + } + } + + project_key +} + +pub fn normalize_project_key(project_root: &Path) -> String { + let absolute = absolutize(project_root); + normalize_project_key_string(&absolute.to_string_lossy()) +} + +fn project_keys_match(stored_key: &str, project_key: &str) -> bool { + let normalized_stored = normalize_project_key_string(stored_key); + if same_os_path_key(&normalized_stored, project_key) { + return true; + } + + canonical_project_key(Path::new(stored_key)) + .is_some_and(|canonical| same_os_path_key(&canonical, project_key)) +} + +fn absolutize(project_root: &Path) -> PathBuf { + if let Ok(canonical) = project_root.canonicalize() { + return canonical; + } + if project_root.is_absolute() { + return project_root.to_path_buf(); + } + std::env::current_dir() + .map_or_else(|_| project_root.to_path_buf(), |cwd| cwd.join(project_root)) +} + +fn normalize_project_key_string(raw: &str) -> String { + let mut normalized = raw.trim().replace('\\', "/"); + if let Some(stripped) = normalized.strip_prefix("//?/UNC/") { + normalized = format!("//{stripped}"); + } else if let Some(stripped) = normalized.strip_prefix("//?/") { + let stripped = stripped.to_owned(); + normalized = stripped; + } + + let (prefix, root_segments, rest) = split_root_prefix(&normalized); + let mut normalized_segments = Vec::new(); + for segment in rest.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + if !normalized_segments.is_empty() { + normalized_segments.pop(); + } + continue; + } + normalized_segments.push(segment); + } + + let mut result = String::new(); + result.push_str(&prefix); + for segment in root_segments { + if !result.ends_with('/') { + result.push('/'); + } + result.push_str(segment); + } + for segment in normalized_segments { + if !result.ends_with('/') && !result.is_empty() { + result.push('/'); + } + result.push_str(segment); + } + + if result.is_empty() { normalized } else { trim_trailing_separators(result) } +} + +fn split_root_prefix(normalized: &str) -> (String, Vec<&str>, &str) { + if let Some(rest) = normalized.strip_prefix("//") { + let mut parts = rest.split('/').filter(|segment| !segment.is_empty()); + let server = parts.next(); + let share = parts.next(); + if let (Some(server), Some(share)) = (server, share) { + let consumed = format!("//{server}/{share}"); + let tail = normalized.strip_prefix(&consumed).unwrap_or_default(); + let rest = tail.trim_start_matches('/'); + return ("//".to_owned(), vec![server, share], rest); + } + return ("//".to_owned(), Vec::new(), rest); + } + + if normalized.len() >= 2 && normalized.as_bytes()[1] == b':' { + let mut chars = normalized.chars(); + let first = chars.next().unwrap_or_default().to_ascii_uppercase(); + let drive = if normalized[2..].starts_with('/') { + format!("{first}:/") + } else { + format!("{first}:") + }; + let rest = normalized[2..].trim_start_matches('/'); + return (drive, Vec::new(), rest); + } + + if let Some(rest) = normalized.strip_prefix('/') { + return ("/".to_owned(), Vec::new(), rest); + } + + (String::new(), Vec::new(), normalized) +} + +fn trim_trailing_separators(mut normalized: String) -> String { + let minimum_len = if normalized == "//" { + 2 + } else if normalized.len() >= 3 + && normalized.as_bytes()[1] == b':' + && normalized.as_bytes()[2] == b'/' + { + 3 + } else { + usize::from(normalized.starts_with('/')) + }; + while normalized.len() > minimum_len && normalized.ends_with('/') { + normalized.pop(); + } + normalized +} + +fn same_os_path_key(left: &str, right: &str) -> bool { + if cfg!(windows) { left.eq_ignore_ascii_case(right) } else { left == right } +} + +fn canonical_project_key(project_root: &Path) -> Option { + let canonical = project_root.canonicalize().ok()?; + Some(normalize_project_key_string(&canonical.to_string_lossy())) +} + +fn trust_value(value: &Value) -> Option { + value.as_object()?.get(TRUST_FIELD)?.as_bool() +} + +fn ensure_object_mut(document: &mut Value) -> &mut Map { + if !document.is_object() { + *document = Value::Object(Map::new()); + } + + match document { + Value::Object(object) => object, + _ => unreachable!("document must be an object after normalization"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[cfg(windows)] + #[test] + fn read_status_accepts_equivalent_backslash_entry() { + let document = json!({ + "projects": { + r"C:\Users\Simon Peter Rothgang\Desktop\claude_rust": { + "hasTrustDialogAccepted": true + } + } + }); + + let lookup = read_status( + &document, + Path::new(r"c:\Users\Simon Peter Rothgang\Desktop\claude_rust\"), + ); + + assert!(lookup.trusted); + assert_eq!(lookup.project_key, "C:/Users/Simon Peter Rothgang/Desktop/claude_rust"); + } + + #[cfg(not(windows))] + #[test] + fn read_status_accepts_equivalent_normalized_entry() { + let document = json!({ + "projects": { + "/home/user/work/project/": { + "hasTrustDialogAccepted": true + } + } + }); + + let lookup = read_status(&document, Path::new("/home/user/work/project")); + + assert!(lookup.trusted); + assert_eq!(lookup.project_key, "/home/user/work/project"); + } + + #[cfg(windows)] + #[test] + fn read_status_treats_any_equivalent_true_entry_as_trusted() { + let document = json!({ + "projects": { + "C:/Users/Simon Peter Rothgang/Desktop/claude_rust": { + "hasTrustDialogAccepted": false + }, + r"C:\Users\Simon Peter Rothgang\Desktop\claude_rust": { + "hasTrustDialogAccepted": true + } + } + }); + + let lookup = + read_status(&document, Path::new(r"C:\Users\Simon Peter Rothgang\Desktop\claude_rust")); + + assert!(lookup.trusted); + } + + #[cfg(not(windows))] + #[test] + fn read_status_treats_any_equivalent_true_entry_as_trusted() { + let document = json!({ + "projects": { + "/home/user/work/project": { + "hasTrustDialogAccepted": false + }, + "/home/user/work/project/": { + "hasTrustDialogAccepted": true + } + } + }); + + let lookup = read_status(&document, Path::new("/home/user/work/project")); + + assert!(lookup.trusted); + } + + #[test] + fn set_trusted_preserves_unknown_project_fields() { + let project_path = + if cfg!(windows) { "C:/work/project" } else { "/home/user/work/project" }; + + let mut document = serde_json::json!({ + "projects": {}, + "theme": "dark" + }); + document["projects"][project_path] = json!({ + "allowedTools": [], + "hasTrustDialogAccepted": false + }); + + let project_key = set_trusted(&mut document, Path::new(project_path)); + + let mut expected = json!({ + "projects": {}, + "theme": "dark" + }); + expected["projects"][project_path] = json!({ + "allowedTools": [], + "hasTrustDialogAccepted": true + }); + assert_eq!(document, expected); + assert_eq!(project_key, project_path); + } + + #[test] + fn normalize_project_key_uppercases_drive_and_trims_trailing_separator() { + let normalized = normalize_project_key_string(r"c:\work\project\"); + + assert_eq!(normalized, "C:/work/project"); + } + + #[test] + fn normalize_project_key_collapses_dot_segments_for_windows_paths() { + let normalized = normalize_project_key_string(r"c:\work\demo\..\project\.\"); + + assert_eq!(normalized, "C:/work/project"); + } + + #[test] + fn normalize_project_key_preserves_unc_root_structure() { + let normalized = normalize_project_key_string(r"\\server\share\team\..\project\"); + + assert_eq!(normalized, "//server/share/project"); + } + + #[test] + fn normalize_project_key_handles_posix_paths() { + let normalized = normalize_project_key_string("/Users/simon/work/../project/"); + + assert_eq!(normalized, "/Users/simon/project"); + } + + #[cfg(windows)] + #[test] + fn read_status_accepts_case_differences_for_existing_windows_paths() { + let dir = tempfile::tempdir().expect("tempdir"); + let project_root = dir.path().join("TrustCaseProject"); + std::fs::create_dir_all(&project_root).expect("create project"); + + let stored_key = + normalize_project_key(&project_root).replace('/', "\\").to_ascii_lowercase(); + let document = json!({ + "projects": { + stored_key: { + "hasTrustDialogAccepted": true + } + } + }); + + let lookup = read_status(&document, &project_root); + + assert!(lookup.trusted); + } + + #[cfg(unix)] + #[test] + fn read_status_accepts_symlink_alias_for_existing_unix_paths() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let project_root = dir.path().join("project"); + let alias_root = dir.path().join("project-link"); + std::fs::create_dir_all(&project_root).expect("create project"); + symlink(&project_root, &alias_root).expect("create symlink"); + + let document = json!({ + "projects": { + alias_root.to_string_lossy().to_string(): { + "hasTrustDialogAccepted": true + } + } + }); + + let lookup = read_status(&document, &project_root); + + assert!(lookup.trusted); + } + + #[cfg(windows)] + #[test] + fn set_trusted_marks_equivalent_windows_aliases_without_rewriting_metadata() { + let dir = tempfile::tempdir().expect("tempdir"); + let project_root = dir.path().join("TrustCaseProject"); + std::fs::create_dir_all(&project_root).expect("create project"); + + let canonical_key = normalize_project_key(&project_root); + let alias_key = canonical_key.replace('/', "\\"); + let mut document = json!({ + "projects": { + alias_key: { + "allowedTools": ["git"], + "hasTrustDialogAccepted": false + } + } + }); + + let project_key = set_trusted(&mut document, &project_root); + + assert_eq!(project_key, canonical_key); + assert_eq!( + document, + json!({ + "projects": { + canonical_key.replace('/', "\\"): { + "allowedTools": ["git"], + "hasTrustDialogAccepted": true + } + } + }) + ); + } + + #[cfg(unix)] + #[test] + fn set_trusted_marks_equivalent_unix_aliases_without_rewriting_metadata() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let project_root = dir.path().join("project"); + let alias_root = dir.path().join("project-link"); + std::fs::create_dir_all(&project_root).expect("create project"); + symlink(&project_root, &alias_root).expect("create symlink"); + + let alias_key = alias_root.to_string_lossy().to_string(); + let mut document = json!({ + "projects": { + alias_key: { + "allowedTools": ["git"], + "hasTrustDialogAccepted": false + } + } + }); + + let project_key = set_trusted(&mut document, &project_root); + + assert_eq!(project_key, normalize_project_key(&project_root)); + assert_eq!( + document, + json!({ + "projects": { + alias_root.to_string_lossy().to_string(): { + "allowedTools": ["git"], + "hasTrustDialogAccepted": true + } + } + }) + ); + } +} diff --git a/claude-code-rust/src/app/update_check.rs b/claude-code-rust/src/app/update_check.rs new file mode 100644 index 0000000..940bedf --- /dev/null +++ b/claude-code-rust/src/app/update_check.rs @@ -0,0 +1,229 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::App; +use crate::Cli; +use crate::agent::events::ClientEvent; +use reqwest::header::{ACCEPT, HeaderMap, HeaderValue, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const UPDATE_CHECK_DISABLE_ENV: &str = "CLAUDE_RUST_NO_UPDATE_CHECK"; +const UPDATE_CHECK_TTL_SECS: u64 = 24 * 60 * 60; +const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(4); +const GITHUB_LATEST_RELEASE_API_URL: &str = + "https://api.github.com/repos/srothgan/claude-code-rust/releases/latest"; +const GITHUB_API_ACCEPT_VALUE: &str = "application/vnd.github+json"; +const GITHUB_API_VERSION_VALUE: &str = "2022-11-28"; +const GITHUB_USER_AGENT_VALUE: &str = "claude-code-rust-update-check"; +const CACHE_FILE: &str = "update-check.json"; +const CACHE_DIR_NAME: &str = "claude-code-rust"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct SimpleVersion { + major: u64, + minor: u64, + patch: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UpdateCheckCache { + checked_at_unix_secs: u64, + latest_version: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubLatestRelease { + tag_name: String, +} + +pub fn start_update_check(app: &App, cli: &Cli) { + if update_check_disabled(cli.no_update_check) { + tracing::debug!("Skipping update check (disabled by flag/env)"); + return; + } + + let event_tx = app.event_tx.clone(); + let current_version = env!("CARGO_PKG_VERSION").to_owned(); + + tokio::task::spawn_local(async move { + let latest_version = resolve_latest_version().await; + let Some(latest_version) = latest_version else { + return; + }; + + if is_newer_version(&latest_version, ¤t_version) { + let _ = event_tx.send(ClientEvent::UpdateAvailable { latest_version, current_version }); + } + }); +} + +fn update_check_disabled(no_update_check_flag: bool) -> bool { + if no_update_check_flag { + return true; + } + std::env::var(UPDATE_CHECK_DISABLE_ENV) + .ok() + .is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) +} + +async fn resolve_latest_version() -> Option { + let cache_path = update_cache_path()?; + let now = unix_now_secs()?; + let cached = read_cache(&cache_path).await; + + if let Some(cache) = cached.as_ref() + && now.saturating_sub(cache.checked_at_unix_secs) <= UPDATE_CHECK_TTL_SECS + && is_valid_version(&cache.latest_version) + { + return Some(cache.latest_version.clone()); + } + + match fetch_latest_release_tag().await { + Some(latest_version) => { + let cache = UpdateCheckCache { checked_at_unix_secs: now, latest_version }; + if let Err(err) = write_cache(&cache_path, &cache).await { + tracing::debug!("update-check cache write failed: {err}"); + } + Some(cache.latest_version) + } + None => cached.and_then(|cache| { + is_valid_version(&cache.latest_version).then_some(cache.latest_version) + }), + } +} + +fn update_cache_path() -> Option { + dirs::cache_dir().map(|dir| dir.join(CACHE_DIR_NAME).join(CACHE_FILE)) +} + +fn unix_now_secs() -> Option { + SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()) +} + +async fn read_cache(path: &Path) -> Option { + let content = tokio::fs::read_to_string(path).await.ok()?; + serde_json::from_str::(&content).ok() +} + +async fn write_cache(path: &Path, cache: &UpdateCheckCache) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let content = serde_json::to_vec(cache)?; + tokio::fs::write(path, content).await?; + Ok(()) +} + +async fn fetch_latest_release_tag() -> Option { + let client = reqwest::Client::builder().timeout(UPDATE_CHECK_TIMEOUT).build().ok()?; + + let response = client + .get(GITHUB_LATEST_RELEASE_API_URL) + .headers(github_api_headers()) + .send() + .await + .ok()?; + + if !response.status().is_success() { + tracing::debug!("update-check request failed with status {}", response.status()); + return None; + } + + let release = response.json::().await.ok()?; + normalize_version_string(&release.tag_name) +} + +fn github_api_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static(GITHUB_API_ACCEPT_VALUE)); + headers.insert("X-GitHub-Api-Version", HeaderValue::from_static(GITHUB_API_VERSION_VALUE)); + headers.insert(USER_AGENT, HeaderValue::from_static(GITHUB_USER_AGENT_VALUE)); + headers +} + +fn normalize_version_string(raw: &str) -> Option { + parse_simple_version(raw).map(|v| format!("{}.{}.{}", v.major, v.minor, v.patch)) +} + +fn parse_simple_version(raw: &str) -> Option { + let trimmed = raw.trim(); + let without_prefix = trimmed.strip_prefix('v').unwrap_or(trimmed); + let core = without_prefix.split_once('-').map_or(without_prefix, |(c, _)| c); + + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some(SimpleVersion { major, minor, patch }) +} + +fn is_valid_version(version: &str) -> bool { + parse_simple_version(version).is_some() +} + +fn is_newer_version(candidate: &str, current: &str) -> bool { + let Some(candidate) = parse_simple_version(candidate) else { + return false; + }; + let Some(current) = parse_simple_version(current) else { + return false; + }; + candidate > current +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_simple_version_accepts_v_prefix() { + assert_eq!( + parse_simple_version("v1.2.3"), + Some(SimpleVersion { major: 1, minor: 2, patch: 3 }) + ); + } + + #[test] + fn parse_simple_version_rejects_invalid_shapes() { + assert_eq!(parse_simple_version("1.2"), None); + assert_eq!(parse_simple_version("1.2.3.4"), None); + assert_eq!(parse_simple_version("v1.two.3"), None); + } + + #[test] + fn parse_simple_version_ignores_prerelease_suffix() { + assert_eq!( + parse_simple_version("v2.4.6-rc1"), + Some(SimpleVersion { major: 2, minor: 4, patch: 6 }) + ); + } + + #[test] + fn normalize_version_string_accepts_release_tag() { + assert_eq!(normalize_version_string("v0.10.0").as_deref(), Some("0.10.0")); + } + + #[test] + fn github_release_payload_parses_tag_name() { + let payload = r#"{"tag_name":"v0.11.0"}"#; + let parsed = serde_json::from_str::(payload).ok(); + assert_eq!(parsed.map(|r| r.tag_name), Some("v0.11.0".to_owned())); + } + + #[test] + fn update_check_disabled_prefers_flag() { + assert!(update_check_disabled(true)); + } + + #[test] + fn is_newer_version_compares_semver_triplets() { + assert!(is_newer_version("0.3.0", "0.2.9")); + assert!(!is_newer_version("0.2.9", "0.3.0")); + assert!(!is_newer_version("bad", "0.3.0")); + } +} diff --git a/claude-code-rust/src/app/usage/cli.rs b/claude-code-rust/src/app/usage/cli.rs new file mode 100644 index 0000000..1cc63aa --- /dev/null +++ b/claude-code-rust/src/app/usage/cli.rs @@ -0,0 +1,305 @@ +use crate::app::{UsageSnapshot, UsageSourceKind, UsageWindow}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +const CLI_USAGE_TIMEOUT: Duration = Duration::from_secs(15); + +pub(super) async fn fetch_snapshot(cwd_raw: String) -> Result { + let claude_path = resolve_claude_path()?; + let output = tokio::time::timeout( + CLI_USAGE_TIMEOUT, + tokio::process::Command::new(&claude_path) + .args(["/usage", "--allowed-tools", ""]) + .current_dir(cwd_raw) + .output(), + ) + .await + .map_err(|_| "Claude CLI usage probe timed out.".to_owned())? + .map_err(|error| format!("Failed to run `claude /usage`: {error}"))?; + + let combined = combine_output(&output.stdout, &output.stderr); + if let Ok(snapshot) = parse_usage_output(&combined) { + return Ok(snapshot); + } + + if !output.status.success() { + let exit_code = + output.status.code().map_or_else(|| "unknown".to_owned(), |code| code.to_string()); + let detail = combined.trim(); + if detail.is_empty() { + return Err(format!("`claude /usage` failed with exit code {exit_code}.")); + } + return Err(format!( + "`claude /usage` failed with exit code {exit_code}: {}", + detail.replace('\n', " ") + )); + } + + parse_usage_output(&combined) +} + +fn resolve_claude_path() -> Result { + which::which("claude").map_err(|_| "claude CLI not found in PATH.".to_owned()) +} + +fn combine_output(stdout: &[u8], stderr: &[u8]) -> String { + let mut text = String::from_utf8_lossy(stdout).into_owned(); + let stderr_text = String::from_utf8_lossy(stderr); + if !stderr_text.trim().is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(&stderr_text); + } + text +} + +fn parse_usage_output(text: &str) -> Result { + let clean = strip_ansi(text); + let trimmed = clean.trim(); + if trimmed.is_empty() { + return Err("Claude CLI usage output was empty.".to_owned()); + } + if let Some(error) = extract_usage_error(trimmed) { + return Err(error); + } + + let panel = trim_to_latest_usage_panel(trimmed).unwrap_or(trimmed); + let five_hour = extract_window(panel, &["Current session"], "5-hour") + .ok_or_else(|| "Could not parse Claude usage: missing Current session.".to_owned())?; + let seven_day = extract_window(panel, &["Current week (all models)"], "7-day"); + let seven_day_sonnet = extract_window( + panel, + &["Current week (Sonnet only)", "Current week (Sonnet)"], + "7-day Sonnet", + ); + let seven_day_opus = extract_window(panel, &["Current week (Opus)"], "7-day Opus"); + + Ok(UsageSnapshot { + source: UsageSourceKind::Cli, + fetched_at: SystemTime::now(), + five_hour: Some(five_hour), + seven_day, + seven_day_opus, + seven_day_sonnet, + extra_usage: None, + }) +} + +fn extract_window(text: &str, labels: &[&str], window_label: &'static str) -> Option { + let lines = text.lines().collect::>(); + let normalized_labels = + labels.iter().map(|label| normalized_for_label_search(label)).collect::>(); + + for (index, line) in lines.iter().enumerate() { + let normalized_line = normalized_for_label_search(line); + if !normalized_labels.iter().any(|label| normalized_line.contains(label)) { + continue; + } + + let window = lines.iter().skip(index).take(12).copied().collect::>(); + let utilization = window.iter().find_map(|candidate| percent_used_from_line(candidate))?; + let reset_description = window.iter().find_map(|candidate| { + let normalized = normalized_for_label_search(candidate); + if normalized.contains("resets") { + let trimmed = candidate.trim(); + if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } + } else { + None + } + }); + + return Some(UsageWindow { + label: window_label, + utilization, + resets_at: None, + reset_description, + }); + } + + None +} + +fn percent_used_from_line(line: &str) -> Option { + if is_likely_status_context_line(line) { + return None; + } + + let percent_index = line.find('%')?; + let number = parse_number_before_percent(line, percent_index)?; + let lower = line.to_ascii_lowercase(); + let used_keywords = ["used", "spent", "consumed"]; + let remaining_keywords = ["left", "remaining", "available"]; + + let normalized = if used_keywords.iter().any(|keyword| lower.contains(keyword)) { + number + } else if remaining_keywords.iter().any(|keyword| lower.contains(keyword)) { + 100.0 - number + } else { + return None; + }; + + Some(normalized.clamp(0.0, 100.0)) +} + +fn parse_number_before_percent(line: &str, percent_index: usize) -> Option { + let prefix = &line[..percent_index]; + let mut collected = String::new(); + + for ch in prefix.chars().rev() { + if ch.is_ascii_digit() || ch == '.' { + collected.push(ch); + } else if !collected.is_empty() { + break; + } + } + + if collected.is_empty() { + return None; + } + + let number = collected.chars().rev().collect::(); + number.parse::().ok() +} + +fn is_likely_status_context_line(line: &str) -> bool { + if !line.contains('|') { + return false; + } + let lower = line.to_ascii_lowercase(); + ["opus", "sonnet", "haiku", "default"].iter().any(|token| lower.contains(token)) +} + +fn extract_usage_error(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + let compact = normalized_for_label_search(text); + if lower.contains("failed to load usage data") || compact.contains("failedtoloadusagedata") { + return Some( + "Claude CLI could not load usage data. Open Claude directly and retry `/usage`." + .to_owned(), + ); + } + if lower.contains("rate limited") { + return Some( + "Claude CLI usage endpoint is rate limited right now. Please try again later." + .to_owned(), + ); + } + None +} + +fn trim_to_latest_usage_panel(text: &str) -> Option<&str> { + text.rfind("Current session").map(|index| &text[index..]) +} + +fn normalized_for_label_search(text: &str) -> String { + text.chars().filter(char::is_ascii_alphanumeric).flat_map(char::to_lowercase).collect() +} + +fn strip_ansi(text: &str) -> String { + enum State { + Normal, + Escape, + Csi, + Osc, + OscEscape, + } + + let mut out = String::with_capacity(text.len()); + let mut state = State::Normal; + + for ch in text.chars() { + state = match state { + State::Normal => { + if ch == '\u{1b}' { + State::Escape + } else { + out.push(ch); + State::Normal + } + } + State::Escape => match ch { + '[' => State::Csi, + ']' => State::Osc, + _ => State::Normal, + }, + State::Csi => { + if ('\u{40}'..='\u{7e}').contains(&ch) { + State::Normal + } else { + State::Csi + } + } + State::Osc => match ch { + '\u{07}' => State::Normal, + '\u{1b}' => State::OscEscape, + _ => State::Osc, + }, + State::OscEscape => { + if ch == '\\' { + State::Normal + } else { + State::Osc + } + } + }; + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_cli_usage_panel() { + let sample = r" + Settings: Status Config Usage + Current session + 15% used + Resets Feb 12 at 1:30pm (Asia/Calcutta) + + Current week (all models) + 3% used + Resets Feb 12 at 1:30pm (Asia/Calcutta) + + Current week (Sonnet only) + 1% used + Resets Feb 12 at 1:30pm (Asia/Calcutta) + "; + + let snapshot = parse_usage_output(sample).expect("snapshot"); + assert_eq!(snapshot.five_hour.as_ref().map(|window| window.utilization), Some(15.0)); + assert_eq!(snapshot.seven_day.as_ref().map(|window| window.utilization), Some(3.0)); + assert_eq!(snapshot.seven_day_sonnet.as_ref().map(|window| window.utilization), Some(1.0)); + } + + #[test] + fn ignores_status_bar_percentage_noise() { + let sample = r" + Claude Code v2.1.32 + 01:07 | | Opus 4.6 | default | 0% left + Current session + 10% used + Current week (all models) + 20% used + "; + + let snapshot = parse_usage_output(sample).expect("snapshot"); + assert_eq!(snapshot.five_hour.as_ref().map(|window| window.utilization), Some(10.0)); + assert_eq!(snapshot.seven_day.as_ref().map(|window| window.utilization), Some(20.0)); + } + + #[test] + fn reports_loading_error() { + let sample = "Settings: Status Config Usage\nLoading usage data..."; + let error = parse_usage_output(sample).expect_err("parse should fail"); + assert!(error.contains("missing Current session") || error.contains("usage")); + } + + #[test] + fn converts_remaining_percent_to_used_percent() { + assert_eq!(percent_used_from_line("85% left"), Some(15.0)); + } +} diff --git a/claude-code-rust/src/app/usage/mod.rs b/claude-code-rust/src/app/usage/mod.rs new file mode 100644 index 0000000..e9128f6 --- /dev/null +++ b/claude-code-rust/src/app/usage/mod.rs @@ -0,0 +1,228 @@ +mod cli; +mod oauth; + +use crate::agent::events::ClientEvent; +use crate::app::{App, UsageSnapshot, UsageSourceKind, UsageSourceMode, UsageWindow}; +use std::time::{Duration, SystemTime}; + +const USAGE_REFRESH_TTL: Duration = Duration::from_secs(30); + +struct UsageRefreshFailure { + source: UsageSourceKind, + message: String, +} + +pub(crate) fn request_refresh_if_needed(app: &mut App) { + if app.usage.in_flight { + return; + } + if app.usage.snapshot.as_ref().is_some_and(is_snapshot_fresh) { + return; + } + request_refresh(app); +} + +pub(crate) fn request_refresh(app: &mut App) { + if app.usage.in_flight || tokio::runtime::Handle::try_current().is_err() { + return; + } + + apply_refresh_started(app); + + let event_tx = app.event_tx.clone(); + let epoch = app.session_scope_epoch; + let source_mode = app.usage.active_source; + let cwd_raw = app.cwd_raw.clone(); + + tokio::task::spawn_local(async move { + let _ = event_tx.send(ClientEvent::UsageRefreshStarted { epoch }); + match refresh_snapshot(source_mode, cwd_raw).await { + Ok(snapshot) => { + let _ = event_tx.send(ClientEvent::UsageSnapshotReceived { epoch, snapshot }); + } + Err(error) => { + let _ = event_tx.send(ClientEvent::UsageRefreshFailed { + epoch, + message: error.message, + source: error.source, + }); + } + } + }); +} + +pub(crate) fn apply_refresh_started(app: &mut App) { + app.usage.in_flight = true; + app.usage.last_error = None; + app.usage.last_attempted_source = None; +} + +pub(crate) fn apply_refresh_success(app: &mut App, snapshot: UsageSnapshot) { + app.usage.last_attempted_source = Some(snapshot.source); + app.usage.snapshot = Some(snapshot); + app.usage.in_flight = false; + app.usage.last_error = None; +} + +pub(crate) fn apply_refresh_failure(app: &mut App, message: String, source: UsageSourceKind) { + app.usage.in_flight = false; + app.usage.last_error = Some(message); + app.usage.last_attempted_source = Some(source); +} + +pub(crate) fn reset_for_session_change(app: &mut App) { + app.usage.snapshot = None; + app.usage.in_flight = false; + app.usage.last_error = None; + app.usage.last_attempted_source = None; +} + +pub(crate) fn visible_windows(snapshot: &UsageSnapshot) -> Vec<&UsageWindow> { + let mut windows = Vec::new(); + if let Some(window) = snapshot.five_hour.as_ref() { + windows.push(window); + } + if let Some(window) = snapshot.seven_day.as_ref() { + windows.push(window); + } + if let Some(window) = snapshot.seven_day_sonnet.as_ref() { + windows.push(window); + } + if let Some(window) = snapshot.seven_day_opus.as_ref() { + windows.push(window); + } + windows +} + +pub(crate) fn format_window_reset(window: &UsageWindow) -> Option { + if let Some(resets_at) = window.resets_at { + return Some(format!("resets in {}", format_remaining_until(resets_at))); + } + + let description = window.reset_description.as_deref()?.trim(); + if description.is_empty() { None } else { Some(description.to_owned()) } +} + +fn is_snapshot_fresh(snapshot: &UsageSnapshot) -> bool { + snapshot.fetched_at.elapsed().is_ok_and(|age| age < USAGE_REFRESH_TTL) +} + +fn format_remaining_until(target: SystemTime) -> String { + let Ok(remaining) = target.duration_since(SystemTime::now()) else { + return "< 1 minute".to_owned(); + }; + + if remaining < Duration::from_secs(60) { + return "< 1 minute".to_owned(); + } + + let total_minutes = remaining.as_secs() / 60; + let days = total_minutes / (24 * 60); + let hours = (total_minutes % (24 * 60)) / 60; + let minutes = total_minutes % 60; + + if days > 0 { + return format!("{days}d {hours}h"); + } + if hours > 0 { + if minutes == 0 { + return format!("{hours}h"); + } + return format!("{hours}h {minutes}m"); + } + format!("{minutes}m") +} + +async fn refresh_snapshot( + source_mode: UsageSourceMode, + cwd_raw: String, +) -> Result { + match source_mode { + UsageSourceMode::Oauth => oauth::fetch_snapshot().await.map_err(|error| { + UsageRefreshFailure { source: UsageSourceKind::Oauth, message: error.into_message() } + }), + UsageSourceMode::Cli => cli::fetch_snapshot(cwd_raw) + .await + .map_err(|message| UsageRefreshFailure { source: UsageSourceKind::Cli, message }), + UsageSourceMode::Auto => refresh_snapshot_auto(cwd_raw).await, + } +} + +async fn refresh_snapshot_auto(cwd_raw: String) -> Result { + match oauth::fetch_snapshot().await { + Ok(snapshot) => Ok(snapshot), + Err(error) if error.should_fallback_to_cli() => { + let oauth_message = error.into_message(); + cli::fetch_snapshot(cwd_raw).await.map_err(|message| UsageRefreshFailure { + source: UsageSourceKind::Cli, + message: format!( + "OAuth unavailable ({oauth_message}). CLI fallback failed: {message}" + ), + }) + } + Err(error) => Err(UsageRefreshFailure { + source: UsageSourceKind::Oauth, + message: error.into_message(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::UsageSourceKind; + + #[test] + fn formats_day_scale_reset() { + let target = SystemTime::now() + Duration::from_secs(4 * 24 * 60 * 60 + 12 * 60 * 60); + let formatted = format_window_reset(&UsageWindow { + label: "7-day", + utilization: 50.0, + resets_at: Some(target), + reset_description: None, + }) + .expect("formatted reset"); + assert!(formatted.starts_with("resets in 4d ")); + } + + #[test] + fn prefers_reset_description_when_no_timestamp_exists() { + let window = UsageWindow { + label: "7-day", + utilization: 40.0, + resets_at: None, + reset_description: Some("Resets Feb 12 at 1:30pm (Asia/Calcutta)".to_owned()), + }; + assert_eq!( + format_window_reset(&window), + Some("Resets Feb 12 at 1:30pm (Asia/Calcutta)".to_owned()) + ); + } + + #[test] + fn collects_only_present_windows() { + let snapshot = UsageSnapshot { + source: UsageSourceKind::Oauth, + fetched_at: SystemTime::now(), + five_hour: Some(UsageWindow { + label: "5-hour", + utilization: 10.0, + resets_at: None, + reset_description: None, + }), + seven_day: None, + seven_day_opus: Some(UsageWindow { + label: "7-day Opus", + utilization: 30.0, + resets_at: None, + reset_description: None, + }), + seven_day_sonnet: None, + extra_usage: None, + }; + + let labels = + visible_windows(&snapshot).into_iter().map(|window| window.label).collect::>(); + assert_eq!(labels, vec!["5-hour", "7-day Opus"]); + } +} diff --git a/claude-code-rust/src/app/usage/oauth.rs b/claude-code-rust/src/app/usage/oauth.rs new file mode 100644 index 0000000..86d2bf0 --- /dev/null +++ b/claude-code-rust/src/app/usage/oauth.rs @@ -0,0 +1,346 @@ +use crate::app::auth; +use crate::app::{ExtraUsage, UsageSnapshot, UsageSourceKind, UsageWindow}; +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}; +use serde::Deserialize; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20"; +const OAUTH_TIMEOUT: Duration = Duration::from_secs(8); +const FALLBACK_USER_AGENT: &str = "claude-code/unknown"; + +#[derive(Debug)] +pub(super) enum OauthFetchError { + Unavailable(String), + Unauthorized(String), + Failed(String), +} + +impl OauthFetchError { + pub(super) fn should_fallback_to_cli(&self) -> bool { + matches!(self, Self::Unavailable(_) | Self::Unauthorized(_)) + } + + pub(super) fn into_message(self) -> String { + match self { + Self::Unavailable(message) | Self::Unauthorized(message) | Self::Failed(message) => { + message + } + } + } +} + +#[derive(Debug, Deserialize)] +struct OAuthUsagePayload { + five_hour: Option, + seven_day: Option, + seven_day_oauth_apps: Option, + seven_day_opus: Option, + seven_day_sonnet: Option, + iguana_necktie: Option, + extra_usage: Option, +} + +#[derive(Debug, Deserialize)] +struct OAuthUsageWindowPayload { + utilization: Option, + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +struct OAuthExtraUsagePayload { + is_enabled: Option, + monthly_limit: Option, + used_credits: Option, + utilization: Option, + currency: Option, +} + +pub(super) async fn fetch_snapshot() -> Result { + let credentials = auth::load_oauth_credentials().ok_or_else(|| { + OauthFetchError::Unavailable( + "No Claude OAuth credentials found. Run /login to authenticate.".to_owned(), + ) + })?; + + if credentials.expires_at.is_some_and(|expires_at| expires_at <= SystemTime::now()) { + return Err(OauthFetchError::Unavailable( + "Claude OAuth credentials expired. Run /login to refresh them.".to_owned(), + )); + } + + let client = reqwest::Client::builder() + .timeout(OAUTH_TIMEOUT) + .default_headers(oauth_headers(&credentials.access_token)?) + .build() + .map_err(|error| { + OauthFetchError::Failed(format!("Failed to create OAuth client: {error}")) + })?; + + let response = + client.get(OAUTH_USAGE_URL).send().await.map_err(|error| { + OauthFetchError::Failed(format!("Claude OAuth network error: {error}")) + })?; + + let status = response.status(); + let body = response.bytes().await.map_err(|error| { + OauthFetchError::Failed(format!("Failed to read Claude OAuth usage response: {error}")) + })?; + + match status.as_u16() { + 200 => decode_usage_payload(&body), + 401 | 403 => Err(OauthFetchError::Unauthorized( + "Claude OAuth usage request was rejected. Run /login to refresh Claude credentials." + .to_owned(), + )), + _ => Err(OauthFetchError::Failed(format!( + "Claude OAuth usage request failed with HTTP {}{}", + status.as_u16(), + truncated_body_suffix(&body), + ))), + } +} + +fn oauth_headers(access_token: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert("anthropic-beta", HeaderValue::from_static(OAUTH_BETA_HEADER)); + headers.insert(USER_AGENT, HeaderValue::from_static(FALLBACK_USER_AGENT)); + let token = HeaderValue::from_str(&format!("Bearer {access_token}")).map_err(|error| { + OauthFetchError::Failed(format!("Invalid OAuth bearer token header: {error}")) + })?; + headers.insert(AUTHORIZATION, token); + Ok(headers) +} + +fn decode_usage_payload(body: &[u8]) -> Result { + let payload = serde_json::from_slice::(body).map_err(|error| { + OauthFetchError::Failed(format!("Failed to decode Claude OAuth usage response: {error}")) + })?; + + let five_hour = map_window(payload.five_hour, "5-hour"); + if five_hour.is_none() { + return Err(OauthFetchError::Failed( + "Claude OAuth usage response did not include the current session window.".to_owned(), + )); + } + + let _ = payload.seven_day_oauth_apps; + let _ = payload.iguana_necktie; + + Ok(UsageSnapshot { + source: UsageSourceKind::Oauth, + fetched_at: SystemTime::now(), + five_hour, + seven_day: map_window(payload.seven_day, "7-day"), + seven_day_opus: map_window(payload.seven_day_opus, "7-day Opus"), + seven_day_sonnet: map_window(payload.seven_day_sonnet, "7-day Sonnet"), + extra_usage: map_extra_usage(payload.extra_usage), + }) +} + +fn map_window( + payload: Option, + label: &'static str, +) -> Option { + let payload = payload?; + let utilization = payload.utilization?; + Some(UsageWindow { + label, + utilization: utilization.clamp(0.0, 100.0), + resets_at: payload.resets_at.as_ref().and_then(parse_timestamp_value), + reset_description: None, + }) +} + +fn map_extra_usage(payload: Option) -> Option { + let payload = payload?; + if payload.is_enabled == Some(false) { + return None; + } + + Some(ExtraUsage { + monthly_limit: payload.monthly_limit.map(|value| value / 100.0), + used_credits: payload.used_credits.map(|value| value / 100.0), + utilization: payload.utilization.map(|value| value.clamp(0.0, 100.0)), + currency: payload.currency, + }) +} + +fn truncated_body_suffix(body: &[u8]) -> String { + let text = String::from_utf8_lossy(body).trim().replace('\n', " "); + if text.is_empty() { + return String::new(); + } + + let shortened = if text.chars().count() > 200 { + let mut out = text.chars().take(200).collect::(); + out.push_str("..."); + out + } else { + text + }; + format!(": {shortened}") +} + +fn parse_timestamp_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Number(number) => number + .as_i64() + .or_else(|| number.as_u64().and_then(|raw| i64::try_from(raw).ok())) + .and_then(system_time_from_epoch), + serde_json::Value::String(raw) => parse_iso8601_timestamp(raw) + .or_else(|| raw.trim().parse::().ok().and_then(system_time_from_epoch)), + _ => None, + } +} + +fn system_time_from_epoch(raw: i64) -> Option { + if raw < 0 { + return None; + } + + let raw = u64::try_from(raw).ok()?; + if raw >= 1_000_000_000_000 { + Some(UNIX_EPOCH + Duration::from_millis(raw)) + } else { + Some(UNIX_EPOCH + Duration::from_secs(raw)) + } +} + +fn parse_iso8601_timestamp(raw: &str) -> Option { + let trimmed = raw.trim(); + let (date_part, time_part) = trimmed.split_once('T').or_else(|| trimmed.split_once(' '))?; + + let mut date_iter = date_part.split('-'); + let year = date_iter.next()?.parse::().ok()?; + let month = date_iter.next()?.parse::().ok()?; + let day = date_iter.next()?.parse::().ok()?; + + let (time_only, offset_seconds) = split_time_and_offset(time_part)?; + let mut time_iter = time_only.split(':'); + let hour = time_iter.next()?.parse::().ok()?; + let minute = time_iter.next()?.parse::().ok()?; + let second_and_fraction = time_iter.next().unwrap_or("0"); + let (second_raw, fraction_raw) = + second_and_fraction.split_once('.').unwrap_or((second_and_fraction, "")); + let second = second_raw.parse::().ok()?; + + let mut nanos = 0u32; + let mut factor = 100_000_000u32; + for ch in fraction_raw.chars().take(9) { + let digit = ch.to_digit(10)?; + nanos = nanos.saturating_add(digit.saturating_mul(factor)); + if factor == 0 { + break; + } + factor /= 10; + } + + let days = days_from_civil(year, month, day)?; + let day_seconds = + i64::from(hour) * 60 * 60 + i64::from(minute) * 60 + i64::from(second) - offset_seconds; + let unix_seconds = days.checked_mul(86_400)?.checked_add(day_seconds)?; + if unix_seconds < 0 { + return None; + } + + Some( + UNIX_EPOCH + + Duration::from_secs(u64::try_from(unix_seconds).ok()?) + + Duration::from_nanos(u64::from(nanos)), + ) +} + +fn split_time_and_offset(raw: &str) -> Option<(&str, i64)> { + if let Some(time_only) = raw.strip_suffix('Z') { + return Some((time_only, 0)); + } + + let sign_index = raw + .char_indices() + .skip(1) + .find(|(_, ch)| *ch == '+' || *ch == '-') + .map(|(index, _)| index)?; + let (time_only, offset_raw) = raw.split_at(sign_index); + let sign = if offset_raw.starts_with('-') { -1 } else { 1 }; + let offset_raw = &offset_raw[1..]; + let mut parts = offset_raw.split(':'); + let hours = parts.next()?.parse::().ok()?; + let minutes = parts.next().unwrap_or("0").parse::().ok()?; + Some((time_only, sign * (hours * 60 * 60 + minutes * 60))) +} + +fn days_from_civil(year: i32, month: u32, day: u32) -> Option { + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + let mut year = i64::from(year); + let month = i64::from(month); + let day = i64::from(day); + year -= i64::from(month <= 2); + let era = if year >= 0 { year } else { year - 399 } / 400; + let yoe = year - era * 400; + let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + Some(era * 146_097 + doe - 719_468) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_sparse_oauth_payload() { + let snapshot = decode_usage_payload( + br#"{ + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 5 }, + "unknown_field": true + }"#, + ) + .expect("snapshot"); + + assert_eq!(snapshot.five_hour.as_ref().map(|window| window.utilization), Some(12.5)); + assert_eq!(snapshot.seven_day_sonnet.as_ref().map(|window| window.utilization), Some(5.0)); + assert!(snapshot.seven_day.is_none()); + } + + #[test] + fn decodes_extra_usage_amounts_in_major_units() { + let snapshot = decode_usage_payload( + br#"{ + "five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" }, + "extra_usage": { + "is_enabled": true, + "monthly_limit": 2000, + "used_credits": 1240, + "utilization": 62, + "currency": "USD" + } + }"#, + ) + .expect("snapshot"); + + let extra = snapshot.extra_usage.expect("extra usage"); + assert_eq!(extra.monthly_limit, Some(20.0)); + assert_eq!(extra.used_credits, Some(12.4)); + assert_eq!(extra.utilization, Some(62.0)); + assert_eq!(extra.currency.as_deref(), Some("USD")); + } + + #[test] + fn parses_iso8601_timestamp() { + let parsed = parse_iso8601_timestamp("2025-12-25T12:00:00.000Z").expect("timestamp"); + assert!(parsed > UNIX_EPOCH); + } + + #[test] + fn parses_numeric_millisecond_timestamp() { + let parsed = + parse_timestamp_value(&serde_json::json!(1_735_128_000_000_i64)).expect("timestamp"); + assert!(parsed > UNIX_EPOCH); + } +} diff --git a/claude-code-rust/src/app/view.rs b/claude-code-rust/src/app/view.rs new file mode 100644 index 0000000..471f0be --- /dev/null +++ b/claude-code-rust/src/app/view.rs @@ -0,0 +1,50 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::app::App; +use std::time::Instant; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActiveView { + Chat, + Config, + Trusted, +} + +pub fn set_active_view(app: &mut App, next: ActiveView) { + if app.active_view == next { + return; + } + + clear_transient_view_state(app); + app.active_view = next; + if next == ActiveView::Chat { + app.rebuild_chat_focus_from_state(); + } + app.needs_redraw = true; +} + +fn clear_transient_view_state(app: &mut App) { + app.selection = None; + app.scrollbar_drag = None; + app.active_paste_session = None; + app.pending_paste_session = None; + app.pending_paste_text.clear(); + app.pending_submit = None; + app.help_open = false; + app.help_view = crate::app::HelpView::default(); + app.help_dialog = crate::app::dialog::DialogState::default(); + app.help_visible_count = 0; + app.mention = None; + app.slash = None; + app.subagent = None; + if app.active_view == ActiveView::Config { + app.config.overlay = None; + } + app.release_focus_target(crate::app::FocusTarget::Help); + app.release_focus_target(crate::app::FocusTarget::Mention); + app.paste_burst.on_non_char_key(Instant::now()); +} + +#[cfg(test)] +mod tests; diff --git a/claude-code-rust/src/app/view/tests.rs b/claude-code-rust/src/app/view/tests.rs new file mode 100644 index 0000000..5a94242 --- /dev/null +++ b/claude-code-rust/src/app/view/tests.rs @@ -0,0 +1,151 @@ +use super::*; +use crate::app::config::{ConfigOverlayState, OutputStyle, OutputStyleOverlayState}; +use crate::app::dialog::DialogState; +use crate::app::slash::{SlashContext, SlashState}; +use crate::app::state::types::ScrollbarDragState; +use crate::app::subagent::SubagentState; +use crate::app::{ + FocusTarget, PasteSessionState, SelectionKind, SelectionPoint, SelectionState, TodoItem, + TodoStatus, +}; + +fn busy_view_test_app() -> App { + let mut app = App::test_default(); + app.input.set_text("draft"); + app.selection = Some(SelectionState { + kind: SelectionKind::Chat, + start: SelectionPoint { row: 0, col: 0 }, + end: SelectionPoint { row: 0, col: 4 }, + dragging: true, + }); + app.scrollbar_drag = Some(ScrollbarDragState { thumb_grab_offset: 1 }); + app.pending_submit = Some(app.input.snapshot()); + app.pending_paste_text = "blocked".to_owned(); + app.pending_paste_session = Some(PasteSessionState { + id: 1, + start: SelectionPoint { row: 0, col: 0 }, + placeholder_index: Some(0), + }); + app.active_paste_session = Some(PasteSessionState { + id: 2, + start: SelectionPoint { row: 0, col: 0 }, + placeholder_index: Some(1), + }); + app.mention = Some(crate::app::mention::MentionState::new(0, 0, "rs".to_owned(), vec![])); + app.slash = Some(SlashState { + trigger_row: 0, + trigger_col: 0, + query: "/co".to_owned(), + context: SlashContext::CommandName, + candidates: vec![], + dialog: DialogState::default(), + }); + app.subagent = Some(SubagentState { + trigger_row: 0, + trigger_col: 0, + query: "plan".to_owned(), + candidates: vec![], + dialog: DialogState::default(), + }); + app.show_todo_panel = true; + app.todos = vec![TodoItem { + content: "todo".to_owned(), + status: TodoStatus::Pending, + active_form: "todo".to_owned(), + }]; + app.claim_focus_target(FocusTarget::TodoList); + app.pending_interaction_ids.push("perm-1".to_owned()); + app.claim_focus_target(FocusTarget::Permission); + app +} + +#[test] +fn set_active_view_clears_transient_chat_state_but_keeps_draft() { + let mut app = busy_view_test_app(); + + set_active_view(&mut app, ActiveView::Trusted); + + assert_eq!(app.active_view, ActiveView::Trusted); + assert_eq!(app.input.text(), "draft"); + assert!(app.selection.is_none()); + assert!(app.scrollbar_drag.is_none()); + assert!(app.mention.is_none()); + assert!(app.slash.is_none()); + assert!(app.subagent.is_none()); + assert!(app.pending_paste_text.is_empty()); + assert!(app.pending_paste_session.is_none()); + assert!(app.active_paste_session.is_none()); + assert!(app.pending_submit.is_none()); +} + +#[test] +fn set_active_view_switches_to_config_from_trusted() { + let mut app = busy_view_test_app(); + app.active_view = ActiveView::Trusted; + + set_active_view(&mut app, ActiveView::Config); + + assert_eq!(app.active_view, ActiveView::Config); + assert!(app.selection.is_none()); + assert!(app.pending_paste_text.is_empty()); +} + +#[test] +fn set_active_view_same_view_is_noop() { + let mut app = busy_view_test_app(); + app.needs_redraw = false; + + set_active_view(&mut app, ActiveView::Chat); + + assert_eq!(app.active_view, ActiveView::Chat); + assert!(app.selection.is_some()); + assert!(app.mention.is_some()); + assert!(!app.pending_paste_text.is_empty()); + assert!(app.pending_submit.is_some()); + assert!(!app.needs_redraw); +} + +#[test] +fn set_active_view_restores_permission_focus_when_returning_to_chat() { + let mut app = busy_view_test_app(); + + set_active_view(&mut app, ActiveView::Trusted); + assert_eq!(app.active_view, ActiveView::Trusted); + + set_active_view(&mut app, ActiveView::Chat); + + assert_eq!(app.active_view, ActiveView::Chat); + assert_eq!(app.focus_owner(), crate::app::FocusOwner::Permission); +} + +#[test] +fn set_active_view_closes_help_without_clearing_question_mark_draft() { + let mut app = App::test_default(); + app.input.set_text("?"); + app.help_open = true; + app.help_view = crate::app::HelpView::Subagents; + app.help_visible_count = 7; + + set_active_view(&mut app, ActiveView::Trusted); + assert_eq!(app.input.text(), "?"); + assert!(!app.is_help_active()); + assert_eq!(app.help_view, crate::app::HelpView::Keys); + assert_eq!(app.help_visible_count, 0); + + set_active_view(&mut app, ActiveView::Chat); + assert_eq!(app.input.text(), "?"); + assert!(!app.is_help_active()); +} + +#[test] +fn leaving_config_clears_config_overlay() { + let mut app = App::test_default(); + app.active_view = ActiveView::Config; + app.config.overlay = Some(ConfigOverlayState::OutputStyle(OutputStyleOverlayState { + selected: OutputStyle::Default, + })); + + set_active_view(&mut app, ActiveView::Trusted); + + assert!(app.config.overlay.is_none()); +} diff --git a/claude-code-rust/src/error.rs b/claude-code-rust/src/error.rs new file mode 100644 index 0000000..1916861 --- /dev/null +++ b/claude-code-rust/src/error.rs @@ -0,0 +1,52 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AppError { + #[error("Node.js runtime not found")] + NodeNotFound, + #[error("Agent bridge process failed")] + AdapterCrashed, + #[error("Agent bridge connection failed")] + ConnectionFailed, + #[error("Session not found")] + SessionNotFound, + #[error("Authentication required")] + AuthRequired, +} + +impl AppError { + pub const NODE_NOT_FOUND_EXIT_CODE: i32 = 20; + pub const ADAPTER_CRASHED_EXIT_CODE: i32 = 21; + pub const CONNECTION_FAILED_EXIT_CODE: i32 = 22; + pub const SESSION_NOT_FOUND_EXIT_CODE: i32 = 23; + pub const AUTH_REQUIRED_EXIT_CODE: i32 = 24; + + #[must_use] + pub fn exit_code(&self) -> i32 { + match self { + Self::NodeNotFound => Self::NODE_NOT_FOUND_EXIT_CODE, + Self::AdapterCrashed => Self::ADAPTER_CRASHED_EXIT_CODE, + Self::ConnectionFailed => Self::CONNECTION_FAILED_EXIT_CODE, + Self::SessionNotFound => Self::SESSION_NOT_FOUND_EXIT_CODE, + Self::AuthRequired => Self::AUTH_REQUIRED_EXIT_CODE, + } + } + + #[must_use] + pub fn user_message(&self) -> &'static str { + match self { + Self::NodeNotFound => { + "Node.js runtime not found. Install Node.js and ensure `node` is on PATH." + } + Self::AdapterCrashed => "Agent bridge process crashed or failed to start.", + Self::ConnectionFailed => { + "Failed to establish or maintain the Agent SDK bridge connection." + } + Self::SessionNotFound => "The requested session was not found.", + Self::AuthRequired => { + "Authentication required. Type /login to authenticate, or run `claude auth login` in a terminal." + } + } + } +} diff --git a/claude-code-rust/src/lib.rs b/claude-code-rust/src/lib.rs new file mode 100644 index 0000000..6866678 --- /dev/null +++ b/claude-code-rust/src/lib.rs @@ -0,0 +1,52 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +pub mod agent; +pub mod app; +pub mod error; +pub mod perf; +pub mod ui; + +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(name = "claude-rs", about = "Native Rust terminal for Claude Code")] +#[allow(clippy::struct_excessive_bools)] +pub struct Cli { + /// Resume a previous session by ID + #[arg(long)] + pub resume: Option, + + /// Disable startup update checks. + #[arg(long)] + pub no_update_check: bool, + + /// Working directory (defaults to cwd) + #[arg(long, short = 'C')] + pub dir: Option, + + /// Path to the agent bridge script (defaults to agent-sdk/dist/bridge.js). + #[arg(long)] + pub bridge_script: Option, + + /// Write tracing diagnostics to a file (disabled unless explicitly set). + #[arg(long, value_name = "PATH")] + pub log_file: Option, + + /// Tracing filter directives (example: `info,claude_code_rust::ui=trace`). + /// Falls back to `RUST_LOG` when omitted. + #[arg(long, value_name = "FILTER")] + pub log_filter: Option, + + /// Append to `--log-file` instead of truncating on startup. + #[arg(long)] + pub log_append: bool, + + /// Write frame performance events to a file (requires `--features perf` build). + #[arg(long, value_name = "PATH")] + pub perf_log: Option, + + /// Append to `--perf-log` instead of truncating on startup. + #[arg(long)] + pub perf_append: bool, +} diff --git a/claude-code-rust/src/main.rs b/claude-code-rust/src/main.rs new file mode 100644 index 0000000..bea26ee --- /dev/null +++ b/claude-code-rust/src/main.rs @@ -0,0 +1,132 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use clap::Parser; +use claude_code_rust::Cli; +use claude_code_rust::error::AppError; +use std::fs::OpenOptions; +use std::time::Instant; + +#[allow(clippy::exit)] +fn main() { + if let Err(err) = run() { + if let Some(app_error) = extract_app_error(&err) { + eprintln!("{}", app_error.user_message()); + std::process::exit(app_error.exit_code()); + } + eprintln!("{err}"); + std::process::exit(1); + } +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + init_tracing(&cli)?; + + #[cfg(not(feature = "perf"))] + if cli.perf_log.is_some() { + return Err(anyhow::anyhow!("`--perf-log` requires a binary built with `--features perf`")); + } + + let resolve_started = Instant::now(); + let bridge_launcher = + claude_code_rust::agent::bridge::resolve_bridge_launcher(cli.bridge_script.as_deref())?; + tracing::info!( + "Resolved agent bridge launcher in {:?}: {}", + resolve_started.elapsed(), + bridge_launcher.describe() + ); + + let rt = tokio::runtime::Runtime::new()?; + let local_set = tokio::task::LocalSet::new(); + + rt.block_on(local_set.run_until(async move { + // Phase 1: create app in Connecting state (instant, no I/O) + let mut app = claude_code_rust::app::create_app(&cli); + + // Phase 2: start non-session startup work + TUI. + // The bridge itself is started from the TUI loop only after trust is accepted. + claude_code_rust::app::start_update_check(&app, &cli); + claude_code_rust::app::start_service_status_check(&app); + let result = claude_code_rust::app::run_tui(&mut app).await; + maybe_print_resume_hint(&app, result.is_ok()); + + // Kill any spawned terminal child processes before exiting + claude_code_rust::agent::events::kill_all_terminals(&app.terminals); + + if let Some(app_error) = app.exit_error.take() { + return Err(anyhow::Error::new(app_error)); + } + + result + })) +} + +fn extract_app_error(err: &anyhow::Error) -> Option { + err.chain().find_map(|cause| cause.downcast_ref::().cloned()) +} + +fn init_tracing(cli: &Cli) -> anyhow::Result<()> { + let Some(path) = cli.log_file.as_ref() else { + if std::env::var_os("RUST_LOG").is_some() { + eprintln!( + "RUST_LOG is set, but tracing is disabled without --log-file . \ +Use --log-file to enable diagnostics." + ); + } + return Ok(()); + }; + + let mut directives = cli + .log_filter + .clone() + .or_else(|| std::env::var("RUST_LOG").ok()) + .unwrap_or_else(|| "info".to_owned()); + if !directives.contains("tui_markdown=") { + directives.push_str(",tui_markdown=info"); + } + let filter = tracing_subscriber::EnvFilter::try_new(directives.as_str()) + .map_err(|e| anyhow::anyhow!("invalid tracing filter `{directives}`: {e}"))?; + + let mut options = OpenOptions::new(); + options.create(true).write(true); + if cli.log_append { + options.append(true); + } else { + options.truncate(true); + } + let file = options + .open(path) + .map_err(|e| anyhow::anyhow!("failed to open log file {}: {e}", path.display()))?; + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(file) + .with_ansi(false) + .with_file(true) + .with_line_number(true) + .with_target(true) + .try_init() + .map_err(|e| anyhow::anyhow!("failed to initialize tracing subscriber: {e}"))?; + + tracing::info!( + target: "diagnostics", + version = env!("CARGO_PKG_VERSION"), + log_file = %path.display(), + log_filter = %directives, + log_append = cli.log_append, + "tracing enabled" + ); + + Ok(()) +} + +fn maybe_print_resume_hint(app: &claude_code_rust::app::App, success: bool) { + if !success { + return; + } + let Some(session_id) = app.session_id.as_ref() else { + return; + }; + eprintln!("Resume this session: claude-rs --resume {session_id}"); +} diff --git a/claude-code-rust/src/perf.rs b/claude-code-rust/src/perf.rs new file mode 100644 index 0000000..facd3c7 --- /dev/null +++ b/claude-code-rust/src/perf.rs @@ -0,0 +1,279 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +//! Lightweight per-frame performance logger for rendering instrumentation. +//! +//! Gated behind `--features perf`. When the feature is disabled, all types +//! become zero-size and all methods are no-ops that the compiler eliminates. +//! +//! # Usage +//! +//! ```bash +//! cargo run --features perf -- --perf-log performance.log +//! # Writes JSON lines: +//! # {"run":"...","frame":1234,"ts_ms":1739599900793,"fn":"chat::render_msgs","ms":2.345,"n":42} +//! ``` + +#[cfg(feature = "perf")] +mod enabled { + use std::cell::RefCell; + use std::fs::{File, OpenOptions}; + use std::io::{BufWriter, Write}; + use std::path::Path; + use std::time::{Instant, SystemTime, UNIX_EPOCH}; + + // Thread-local file handle so Timer::drop can log without borrowing PerfLogger. + thread_local! { + pub(crate) static LOG_FILE: RefCell>> = const { RefCell::new(None) }; + static FRAME_COUNTER: RefCell = const { RefCell::new(0) }; + static RUN_ID: RefCell = const { RefCell::new(String::new()) }; + } + + pub struct PerfLogger { + _private: (), + } + + fn unix_ms() -> u128 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |duration| duration.as_millis()) + } + + pub(crate) fn write_entry(name: &'static str, ms: f64, extra: Option<(&'static str, usize)>) { + let frame = FRAME_COUNTER.with(|c| *c.borrow()); + let ts_ms = unix_ms(); + LOG_FILE.with(|f| { + if let Some(ref mut file) = *f.borrow_mut() { + RUN_ID.with(|run| { + let run_id = run.borrow(); + if let Some((k, v)) = extra { + let _ = writeln!( + file, + r#"{{"run":"{run_id}","frame":{frame},"ts_ms":{ts_ms},"fn":"{name}","ms":{ms:.3},"{k}":{v}}}"#, + ); + } else { + let _ = writeln!( + file, + r#"{{"run":"{run_id}","frame":{frame},"ts_ms":{ts_ms},"fn":"{name}","ms":{ms:.3}}}"#, + ); + } + }); + } + }); + } + + #[allow(clippy::unused_self)] + impl PerfLogger { + /// Open (or create) the log file. Returns `None` on I/O error. + pub fn open(path: &Path, append: bool) -> Option { + let mut options = OpenOptions::new(); + options.create(true).write(true); + if append { + options.append(true); + } else { + options.truncate(true); + } + let file = options.open(path).ok()?; + let mut writer = BufWriter::new(file); + let run_id = uuid::Uuid::new_v4().to_string(); + let ts_ms = unix_ms(); + let _ = writeln!( + writer, + r#"{{"event":"run_start","run":"{run_id}","ts_ms":{ts_ms},"pid":{},"version":"{}","append":{append}}}"#, + std::process::id(), + env!("CARGO_PKG_VERSION") + ); + let _ = writer.flush(); + LOG_FILE.with(|f| *f.borrow_mut() = Some(writer)); + RUN_ID.with(|r| *r.borrow_mut() = run_id); + FRAME_COUNTER.with(|c| *c.borrow_mut() = 0); + Some(Self { _private: () }) + } + + /// Increment the frame counter. Call once at the start of each render frame. + pub fn next_frame(&mut self) { + let frame = FRAME_COUNTER.with(|c| { + let mut value = c.borrow_mut(); + *value += 1; + *value + }); + if frame.is_multiple_of(240) { + LOG_FILE.with(|f| { + if let Some(ref mut file) = *f.borrow_mut() { + let _ = file.flush(); + } + }); + } + } + + /// Start a named timer. Logs duration on drop. + #[must_use] + pub fn start(&self, name: &'static str) -> Timer { + Timer { name, start: Instant::now(), extra: None } + } + + /// Start a named timer with an extra numeric field (e.g. message count). + #[must_use] + pub fn start_with( + &self, + name: &'static str, + extra_name: &'static str, + extra_val: usize, + ) -> Timer { + Timer { name, start: Instant::now(), extra: Some((extra_name, extra_val)) } + } + + /// Log an instant marker for the current frame (`ms = 0`). + pub fn mark(&self, name: &'static str) { + write_entry(name, 0.0, None); + } + + /// Log an instant marker with an extra numeric field (`ms = 0`). + pub fn mark_with(&self, name: &'static str, extra_name: &'static str, extra_val: usize) { + write_entry(name, 0.0, Some((extra_name, extra_val))); + } + } + + pub struct Timer { + pub(crate) name: &'static str, + pub(crate) start: Instant, + pub(crate) extra: Option<(&'static str, usize)>, + } + + #[allow(clippy::unused_self)] + impl Timer { + /// Manually stop and log. Useful when you need to end timing before scope exit. + pub fn stop(self) { + // Drop impl handles logging + } + } + + impl Drop for Timer { + fn drop(&mut self) { + let ms = self.start.elapsed().as_secs_f64() * 1000.0; + write_entry(self.name, ms, self.extra); + } + } +} + +#[cfg(not(feature = "perf"))] +mod disabled { + use std::path::Path; + + pub struct PerfLogger; + pub struct Timer; + + #[allow(clippy::unused_self)] + impl PerfLogger { + #[inline] + pub fn open(_path: &Path, _append: bool) -> Option { + None + } + #[inline] + pub fn next_frame(&mut self) {} + #[inline] + #[must_use] + pub fn start(&self, _name: &'static str) -> Timer { + Timer + } + #[inline] + #[must_use] + pub fn start_with( + &self, + _name: &'static str, + _extra_name: &'static str, + _extra_val: usize, + ) -> Timer { + Timer + } + #[inline] + pub fn mark(&self, _name: &'static str) {} + #[inline] + pub fn mark_with(&self, _name: &'static str, _extra_name: &'static str, _extra_val: usize) { + } + } + + #[allow(clippy::unused_self)] + impl Timer { + #[inline] + pub fn stop(self) {} + } +} + +/// Start a timer without needing a `PerfLogger` reference. +/// Uses the thread-local log file directly. Returns `None` (and is a no-op) +/// when the `perf` feature is disabled or no logger has been opened. +#[cfg(feature = "perf")] +#[must_use] +#[inline] +pub fn start(name: &'static str) -> Option { + // Only create a timer if the log file is actually open + enabled::LOG_FILE.with(|f| { + if f.borrow().is_some() { + Some(Timer { name, start: std::time::Instant::now(), extra: None }) + } else { + None + } + }) +} + +#[cfg(feature = "perf")] +#[must_use] +#[inline] +pub fn start_with(name: &'static str, extra_name: &'static str, extra_val: usize) -> Option { + enabled::LOG_FILE.with(|f| { + if f.borrow().is_some() { + Some(Timer { + name, + start: std::time::Instant::now(), + extra: Some((extra_name, extra_val)), + }) + } else { + None + } + }) +} + +#[cfg(not(feature = "perf"))] +#[must_use] +#[inline] +pub fn start(_name: &'static str) -> Option { + None +} + +#[cfg(not(feature = "perf"))] +#[must_use] +#[inline] +pub fn start_with( + _name: &'static str, + _extra_name: &'static str, + _extra_val: usize, +) -> Option { + None +} + +/// Write an instant marker for the current frame (`ms = 0`). +#[cfg(feature = "perf")] +#[inline] +pub fn mark(name: &'static str) { + enabled::write_entry(name, 0.0, None); +} + +#[cfg(not(feature = "perf"))] +#[inline] +pub fn mark(_name: &'static str) {} + +/// Write an instant marker with one numeric field (`ms = 0`). +#[cfg(feature = "perf")] +#[inline] +pub fn mark_with(name: &'static str, extra_name: &'static str, extra_val: usize) { + enabled::write_entry(name, 0.0, Some((extra_name, extra_val))); +} + +#[cfg(not(feature = "perf"))] +#[inline] +pub fn mark_with(_name: &'static str, _extra_name: &'static str, _extra_val: usize) {} + +#[cfg(feature = "perf")] +pub use enabled::{PerfLogger, Timer}; + +#[cfg(not(feature = "perf"))] +pub use disabled::{PerfLogger, Timer}; diff --git a/claude-code-rust/src/ui/autocomplete.rs b/claude-code-rust/src/ui/autocomplete.rs new file mode 100644 index 0000000..9eb5e3e --- /dev/null +++ b/claude-code-rust/src/ui/autocomplete.rs @@ -0,0 +1,556 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::app::App; +use crate::app::mention::MAX_VISIBLE; +use crate::app::{mention, slash, subagent}; +use crate::ui::input; +use crate::ui::theme; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; +use unicode_width::UnicodeWidthChar; + +/// Max dropdown width (characters). +const MAX_WIDTH: u16 = 60; +/// Min dropdown width so list entries stay readable. +const MIN_WIDTH: u16 = 20; +/// Vertical gap (in rows) between the trigger line and the dropdown. +const ANCHOR_VERTICAL_GAP: u16 = 1; + +enum Dropdown<'a> { + Mention(&'a mention::MentionState), + Slash(&'a slash::SlashState), + Subagent(&'a subagent::SubagentState), +} + +struct DropdownMeta { + visible_count: usize, + start: usize, + end: usize, + title: String, +} + +pub fn is_active(app: &App) -> bool { + app.mention.is_some() + || app.slash.as_ref().is_some_and(|s| !s.candidates.is_empty()) + || app.subagent.as_ref().is_some_and(|s| !s.candidates.is_empty()) +} + +#[allow(clippy::cast_possible_truncation)] +pub fn compute_height(app: &App) -> u16 { + let count = if let Some(m) = &app.mention { + m.candidates.len().max(1) + } else if let Some(s) = &app.slash { + s.candidates.len() + } else if let Some(s) = &app.subagent { + s.candidates.len() + } else { + 0 + }; + + if count == 0 { + 0 + } else { + let visible = count.min(MAX_VISIBLE) as u16; + visible.saturating_add(2) // +2 for top/bottom border + } +} + +/// Render the autocomplete dropdown as a floating overlay above the input area. +#[allow(clippy::cast_possible_truncation)] +pub fn render(frame: &mut Frame, input_area: Rect, app: &App) { + let Some(dropdown) = active_dropdown(app) else { + return; + }; + + let height = compute_height(app); + if height == 0 { + return; + } + + let text_area = input::compute_render_geometry(input_area, input::hint_line_count(app)).text; + if text_area.width == 0 || text_area.height == 0 { + return; + } + + let (trigger_row, trigger_col) = dropdown_trigger(&dropdown); + let (anchor_row, anchor_col) = + wrapped_visual_pos(app.input.lines(), trigger_row, trigger_col, text_area.width); + + let anchor_x = text_area.x.saturating_add(anchor_col).min(text_area.right().saturating_sub(1)); + let (x, width) = choose_dropdown_x(anchor_x, text_area.x, text_area.right(), text_area.width); + if width == 0 { + return; + } + + let anchor_y = text_area.y.saturating_add(anchor_row).min(text_area.bottom().saturating_sub(1)); + let y = choose_dropdown_y(anchor_y, height, frame.area().y, frame.area().bottom()); + + let dropdown_area = Rect { x, y, width, height }; + let meta = dropdown_meta(&dropdown); + let lines = dropdown_lines(&dropdown, &meta); + + let block = Block::default() + .title(Span::styled(meta.title, Style::default().fg(theme::DIM))) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme::DIM)); + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(ratatui::widgets::Clear, dropdown_area); + frame.render_widget(paragraph, dropdown_area); +} + +fn active_dropdown(app: &App) -> Option> { + if let Some(m) = &app.mention { + return Some(Dropdown::Mention(m)); + } + if let Some(s) = &app.slash + && !s.candidates.is_empty() + { + return Some(Dropdown::Slash(s)); + } + if let Some(s) = &app.subagent + && !s.candidates.is_empty() + { + return Some(Dropdown::Subagent(s)); + } + None +} + +fn dropdown_trigger(dropdown: &Dropdown<'_>) -> (usize, usize) { + match dropdown { + Dropdown::Mention(m) => (m.trigger_row, m.trigger_col), + Dropdown::Slash(s) => (s.trigger_row, s.trigger_col), + Dropdown::Subagent(s) => (s.trigger_row, s.trigger_col), + } +} + +fn dropdown_meta(dropdown: &Dropdown<'_>) -> DropdownMeta { + match dropdown { + Dropdown::Mention(m) => { + let visible_count = m.candidates.len().clamp(1, MAX_VISIBLE); + let (start, end) = if m.candidates.is_empty() { + (0, 0) + } else { + m.dialog.visible_range(m.candidates.len(), MAX_VISIBLE) + }; + DropdownMeta { visible_count, start, end, title: " Files & Folders ".to_owned() } + } + Dropdown::Slash(s) => { + let visible_count = s.candidates.len().min(MAX_VISIBLE); + let (start, end) = s.dialog.visible_range(s.candidates.len(), MAX_VISIBLE); + let title = match &s.context { + slash::SlashContext::CommandName => format!(" Commands ({}) ", s.candidates.len()), + slash::SlashContext::Argument { command, .. } => { + format!(" {} Args ({}) ", command, s.candidates.len()) + } + }; + DropdownMeta { visible_count, start, end, title } + } + Dropdown::Subagent(s) => { + let visible_count = s.candidates.len().min(MAX_VISIBLE); + let (start, end) = s.dialog.visible_range(s.candidates.len(), MAX_VISIBLE); + DropdownMeta { + visible_count, + start, + end, + title: format!(" Subagents ({}) ", s.candidates.len()), + } + } + } +} + +fn dropdown_lines(dropdown: &Dropdown<'_>, meta: &DropdownMeta) -> Vec> { + let mut lines: Vec> = Vec::with_capacity(meta.visible_count); + match dropdown { + Dropdown::Mention(m) => { + if m.candidates.is_empty() { + lines.push(mention_placeholder_line(m)); + } else { + for (i, candidate) in m.candidates[meta.start..meta.end].iter().enumerate() { + lines.push(mention_candidate_line(m, candidate, meta.start + i)); + } + } + } + Dropdown::Slash(s) => { + for (i, candidate) in s.candidates[meta.start..meta.end].iter().enumerate() { + lines.push(slash_candidate_line(s, candidate, meta.start + i)); + } + } + Dropdown::Subagent(s) => { + for (i, candidate) in s.candidates[meta.start..meta.end].iter().enumerate() { + lines.push(subagent_candidate_line(s, candidate, meta.start + i)); + } + } + } + lines +} + +fn mention_placeholder_line(mention: &mention::MentionState) -> Line<'static> { + let message = mention.placeholder_message().unwrap_or_default(); + Line::from(Span::styled(format!(" {message}"), Style::default().fg(theme::DIM))) +} + +fn mention_candidate_line( + mention: &mention::MentionState, + candidate: &mention::FileCandidate, + global_idx: usize, +) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + push_selection_prefix(&mut spans, global_idx == mention.dialog.selected); + + let path = &candidate.rel_path; + let query = &mention.query; + if query.is_empty() { + spans.push(Span::raw(path.clone())); + } else if let Some((match_start, match_end)) = find_case_insensitive_range(path, query) { + push_highlighted_text(&mut spans, path, match_start, match_end); + } else { + spans.push(Span::raw(path.clone())); + } + + Line::from(spans) +} + +fn slash_candidate_line( + slash: &slash::SlashState, + candidate: &slash::SlashCandidate, + global_idx: usize, +) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + push_selection_prefix(&mut spans, global_idx == slash.dialog.selected); + + if slash.query.is_empty() { + spans.push(Span::raw(candidate.primary.clone())); + } else if matches!(slash.context, slash::SlashContext::CommandName) { + let command_name = &candidate.primary; + let command_body = command_name.strip_prefix('/').unwrap_or(command_name); + if let Some((match_start, match_end)) = + find_case_insensitive_range(command_body, &slash.query) + { + let prefix_len = command_name.len().saturating_sub(command_body.len()); + let start_idx = prefix_len + match_start; + let end_idx = prefix_len + match_end; + push_highlighted_text(&mut spans, command_name, start_idx, end_idx); + } else { + spans.push(Span::raw(command_name.clone())); + } + } else if let Some((match_start, match_end)) = + find_case_insensitive_range(&candidate.primary, &slash.query) + { + push_highlighted_text(&mut spans, &candidate.primary, match_start, match_end); + } else { + spans.push(Span::raw(candidate.primary.clone())); + } + + if let Some(secondary) = &candidate.secondary { + spans.push(Span::styled(" ", Style::default().fg(theme::DIM))); + spans.push(Span::styled(secondary.clone(), Style::default().fg(theme::DIM))); + } + + Line::from(spans) +} + +fn subagent_candidate_line( + subagent: &subagent::SubagentState, + candidate: &subagent::SubagentCandidate, + global_idx: usize, +) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + push_selection_prefix(&mut spans, global_idx == subagent.dialog.selected); + + let primary = format!("&{}", candidate.name); + if subagent.query.is_empty() { + spans.push(Span::raw(primary)); + } else if let Some((match_start, match_end)) = + find_case_insensitive_range(&candidate.name, &subagent.query) + { + push_highlighted_text(&mut spans, &primary, match_start + 1, match_end + 1); + } else { + spans.push(Span::raw(primary)); + } + + let secondary = match (&candidate.description, &candidate.model) { + (desc, Some(model)) if !desc.trim().is_empty() => Some(format!("{desc} | model: {model}")), + (desc, None) if !desc.trim().is_empty() => Some(desc.clone()), + (_, Some(model)) => Some(format!("model: {model}")), + _ => None, + }; + if let Some(secondary) = secondary { + spans.push(Span::styled(" ", Style::default().fg(theme::DIM))); + spans.push(Span::styled(secondary, Style::default().fg(theme::DIM))); + } + + Line::from(spans) +} + +fn push_selection_prefix(spans: &mut Vec>, is_selected: bool) { + if is_selected { + spans.push(Span::styled( + " \u{25b8} ", + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::raw(" ")); + } +} + +#[derive(Clone, Copy)] +struct FoldSegment { + fold_start: usize, + fold_end: usize, + orig_start: usize, + orig_end: usize, +} + +fn find_case_insensitive_range(haystack: &str, needle: &str) -> Option<(usize, usize)> { + if needle.is_empty() || haystack.is_empty() { + return None; + } + + let folded_needle = needle.to_lowercase(); + if folded_needle.is_empty() { + return None; + } + + let mut folded_haystack = String::new(); + let mut segments: Vec = Vec::with_capacity(haystack.chars().count()); + for (orig_start, ch) in haystack.char_indices() { + let orig_end = orig_start + ch.len_utf8(); + let fold_start = folded_haystack.len(); + for lower_ch in ch.to_lowercase() { + folded_haystack.push(lower_ch); + } + let fold_end = folded_haystack.len(); + segments.push(FoldSegment { fold_start, fold_end, orig_start, orig_end }); + } + + let folded_match_start = folded_haystack.find(&folded_needle)?; + let folded_match_end = folded_match_start + folded_needle.len(); + let start_seg = segments + .iter() + .find(|seg| seg.fold_start <= folded_match_start && folded_match_start < seg.fold_end)?; + let end_probe = folded_match_end.saturating_sub(1); + let end_seg = segments + .iter() + .find(|seg| seg.fold_start <= end_probe && end_probe < seg.fold_end) + .unwrap_or(start_seg); + + Some((start_seg.orig_start, end_seg.orig_end)) +} + +fn push_highlighted_text( + spans: &mut Vec>, + text: &str, + match_start: usize, + match_end: usize, +) { + let before = &text[..match_start]; + let matched = &text[match_start..match_end]; + let after = &text[match_end..]; + + if !before.is_empty() { + spans.push(Span::raw(before.to_owned())); + } + spans.push(Span::styled( + matched.to_owned(), + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + )); + if !after.is_empty() { + spans.push(Span::raw(after.to_owned())); + } +} + +fn choose_dropdown_x( + anchor_x: u16, + area_left: u16, + area_right: u16, + text_area_width: u16, +) -> (u16, u16) { + if area_right <= area_left || text_area_width == 0 { + return (area_left, 0); + } + + let preferred_width = text_area_width.clamp(1, MAX_WIDTH); + let width = + if text_area_width >= MIN_WIDTH { preferred_width.max(MIN_WIDTH) } else { preferred_width }; + + let anchor_x = anchor_x.clamp(area_left, area_right.saturating_sub(1)); + let mut x = anchor_x; + if x.saturating_add(width) > area_right { + x = area_right.saturating_sub(width); + } + x = x.max(area_left); + + (x, width) +} + +#[allow(clippy::cast_possible_truncation)] +fn wrapped_visual_pos( + lines: &[String], + target_row: usize, + target_col: usize, + width: u16, +) -> (u16, u16) { + let width = width as usize; + if width == 0 { + return (0, 0); + } + + let mut visual_row: u16 = 0; + for (row, line) in lines.iter().enumerate() { + let mut col_width: usize = 0; + let mut char_idx: usize = 0; + + if row == target_row && target_col == 0 { + return (visual_row, 0); + } + + for ch in line.chars() { + if row == target_row && char_idx == target_col { + return (visual_row, col_width as u16); + } + + let w = UnicodeWidthChar::width(ch).unwrap_or(0); + if w > 0 && col_width + w > width && col_width > 0 { + visual_row = visual_row.saturating_add(1); + col_width = 0; + } + + if w > width && col_width == 0 { + visual_row = visual_row.saturating_add(1); + char_idx += 1; + continue; + } + + if w > 0 { + col_width += w; + } + char_idx += 1; + } + + if row == target_row && char_idx == target_col { + if col_width >= width { + return (visual_row.saturating_add(1), 0); + } + return (visual_row, col_width as u16); + } + + visual_row = visual_row.saturating_add(1); + } + + (visual_row, 0) +} + +fn choose_dropdown_y(anchor_y: u16, height: u16, frame_top: u16, frame_bottom: u16) -> u16 { + if height == 0 || frame_bottom <= frame_top { + return frame_top; + } + + let below_y = anchor_y.saturating_add(1).saturating_add(ANCHOR_VERTICAL_GAP); + let rows_below_with_gap = frame_bottom.saturating_sub(below_y); + let fits_below_with_gap = height <= rows_below_with_gap; + + let above_y = anchor_y.saturating_sub(height.saturating_add(ANCHOR_VERTICAL_GAP)); + let rows_above_with_gap = + anchor_y.saturating_sub(frame_top.saturating_add(ANCHOR_VERTICAL_GAP)); + let fits_above_with_gap = height <= rows_above_with_gap; + + let mut y = if fits_below_with_gap { + below_y + } else if fits_above_with_gap { + above_y + } else if rows_below_with_gap >= rows_above_with_gap { + anchor_y.saturating_add(1) + } else { + anchor_y.saturating_sub(height) + }; + + let max_y = frame_bottom.saturating_sub(height); + y = y.clamp(frame_top, max_y); + + let overlaps_anchor = y <= anchor_y && anchor_y < y.saturating_add(height); + if overlaps_anchor { + let can_place_below = anchor_y.saturating_add(1).saturating_add(height) <= frame_bottom; + let can_place_above = frame_top.saturating_add(height) <= anchor_y; + if can_place_below { + y = anchor_y.saturating_add(1); + } else if can_place_above { + y = anchor_y.saturating_sub(height); + } + } + + y.clamp(frame_top, max_y) +} + +#[cfg(test)] +mod tests { + use super::{ + choose_dropdown_x, choose_dropdown_y, compute_height, find_case_insensitive_range, + is_active, + }; + use crate::app::{App, mention}; + + #[test] + fn dropdown_keeps_preferred_width_and_shifts_left_near_right_edge() { + let (x, width) = choose_dropdown_x(78, 0, 80, 80); + assert_eq!((x, width), (20, 60)); + } + + #[test] + fn dropdown_handles_tiny_area_by_shrinking_width() { + let (x, width) = choose_dropdown_x(7, 5, 10, 5); + assert_eq!((x, width), (5, 5)); + } + + #[test] + fn dropdown_keeps_anchor_when_room_is_available() { + let (x, width) = choose_dropdown_x(12, 0, 80, 80); + assert_eq!((x, width), (12, 60)); + } + + #[test] + fn dropdown_prefers_below_with_gap_when_space_available() { + let y = choose_dropdown_y(10, 4, 0, 30); + assert_eq!(y, 12); + } + + #[test] + fn dropdown_uses_above_with_gap_when_below_too_small() { + let y = choose_dropdown_y(9, 6, 0, 12); + assert_eq!(y, 2); + } + + #[test] + fn dropdown_does_not_cover_anchor_row_when_possible() { + let anchor = 5; + let height = 5; + let y = choose_dropdown_y(anchor, height, 0, 11); + assert!(!(y <= anchor && anchor < y + height)); + } + + #[test] + fn case_insensitive_range_respects_utf8_boundaries() { + let haystack = "İstanbul"; + let (start, end) = + find_case_insensitive_range(haystack, "i").expect("case-insensitive match"); + assert!(haystack.is_char_boundary(start)); + assert!(haystack.is_char_boundary(end)); + assert_eq!(&haystack[start..end], "İ"); + } + + #[test] + fn empty_mention_still_renders_placeholder_dropdown() { + let mut app = App::test_default(); + app.input.set_text("@"); + let _ = app.input.set_cursor(0, 1); + mention::activate(&mut app); + + assert!(is_active(&app)); + assert_eq!(compute_height(&app), 3); + } +} diff --git a/claude-code-rust/src/ui/chat.rs b/claude-code-rust/src/ui/chat.rs new file mode 100644 index 0000000..24a7e86 --- /dev/null +++ b/claude-code-rust/src/ui/chat.rs @@ -0,0 +1,1518 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::app::cache_metrics; +use crate::app::{App, AppStatus, MessageBlock, MessageRole, SelectionKind, SelectionState}; +use crate::ui::message::{self, SpinnerState}; +use crate::ui::theme; +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Paragraph, Widget, Wrap}; +use std::time::Instant; + +/// Minimum number of messages to render above/below the visible range as a margin. +/// Heights are now exact (block-level wrapped heights), so no safety margin is needed. +const CULLING_MARGIN: usize = 0; +const CULLING_OVERSCAN_ROWS: usize = 100; +const SCROLLBAR_MIN_THUMB_HEIGHT: usize = 1; +const SCROLLBAR_TOP_EASE: f32 = 0.35; +const SCROLLBAR_SIZE_EASE: f32 = 0.2; +const SCROLLBAR_EASE_EPSILON: f32 = 0.01; +const OVERSCROLL_CLAMP_EASE: f32 = 0.2; + +#[derive(Clone, Copy, Default)] +struct HeightUpdateStats { + measured_msgs: usize, + measured_lines: usize, + reused_msgs: usize, +} + +#[derive(Clone, Copy, Default)] +struct RemeasureBudget { + remaining_msgs: usize, + remaining_lines: usize, +} + +impl RemeasureBudget { + fn new(viewport_height: usize) -> Self { + let viewport_floor = viewport_height.max(12); + Self { + remaining_msgs: viewport_floor, + remaining_lines: viewport_floor.saturating_mul(8).max(256), + } + } + + fn exhausted(self) -> bool { + self.remaining_msgs == 0 || self.remaining_lines == 0 + } + + fn consume(&mut self, wrapped_lines: usize) { + self.remaining_msgs = self.remaining_msgs.saturating_sub(1); + self.remaining_lines = self.remaining_lines.saturating_sub(wrapped_lines.max(1)); + } +} + +#[derive(Clone, Copy, Default)] +struct CulledRenderStats { + local_scroll: usize, + first_visible: usize, + render_start: usize, + rendered_msgs: usize, + last_rendered_idx: Option, + rendered_line_count: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ScrollbarGeometry { + thumb_top: usize, + thumb_size: usize, +} + +struct ScrolledRenderData { + paragraph: Paragraph<'static>, + stats: CulledRenderStats, + max_scroll: usize, + scroll_offset: usize, +} +/// Build a `SpinnerState` for a specific message index. +fn msg_spinner( + base: SpinnerState, + index: usize, + active_turn_assistant: Option, + msg: &crate::app::ChatMessage, +) -> SpinnerState { + let is_assistant = matches!(msg.role, MessageRole::Assistant); + let is_active_turn_assistant = is_assistant && active_turn_assistant == Some(index); + let has_blocks = !msg.blocks.is_empty(); + SpinnerState { + is_active_turn_assistant, + show_empty_thinking: is_active_turn_assistant && base.show_empty_thinking, + show_thinking: is_active_turn_assistant && base.show_thinking && has_blocks, + show_subagent_thinking: is_active_turn_assistant + && base.show_subagent_thinking + && has_blocks, + show_compacting: is_active_turn_assistant && base.show_compacting, + ..base + } +} + +/// Ensure every message has an up-to-date height in the viewport at the given width. +/// The last message is always recomputed while streaming (content changes each frame). +/// +/// Height is ground truth: each message is rendered into a scratch buffer via +/// `render_message()` and measured with `Paragraph::line_count(width)`. This uses +/// the exact same wrapping algorithm as the actual render path, so heights can +/// never drift from reality. +/// +/// Iterates in reverse so we can break early: once we hit a message whose height +/// is already valid at this width, all earlier messages are also valid (content +/// only changes at the tail during streaming). This turns the common case from +/// O(n) to O(1). +fn update_visual_heights( + app: &mut App, + base: SpinnerState, + width: u16, + viewport_height: usize, +) -> HeightUpdateStats { + let _t = + app.perf.as_ref().map(|p| p.start_with("chat::update_heights", "msgs", app.messages.len())); + app.viewport.sync_message_count(app.messages.len()); + + let msg_count = app.messages.len(); + let is_streaming = matches!(app.status, AppStatus::Thinking | AppStatus::Running); + let active_turn_assistant = app.active_turn_assistant_idx(); + let mut stats = HeightUpdateStats::default(); + + if msg_count == 0 { + app.viewport.finalize_remeasure_if_clean(); + return stats; + } + + let (visible_start, visible_end) = app + .viewport + .remeasure_anchor_window(viewport_height) + .or_else(|| app.viewport.current_visible_window(viewport_height)) + .unwrap_or((0, 0)); + app.viewport.ensure_remeasure_anchor(visible_start, visible_end, msg_count); + + while let Some(i) = app.viewport.next_priority_remeasure() { + let is_last = i + 1 == msg_count; + if !needs_height_measure(app, i, is_last, active_turn_assistant, is_streaming) { + stats.reused_msgs += 1; + continue; + } + measure_message_height_at(app, base, active_turn_assistant, width, i, &mut stats); + } + + for i in visible_start..=visible_end { + let is_last = i + 1 == msg_count; + if !needs_height_measure(app, i, is_last, active_turn_assistant, is_streaming) { + stats.reused_msgs += 1; + continue; + } + measure_message_height_at(app, base, active_turn_assistant, width, i, &mut stats); + } + + if is_streaming { + let last = msg_count.saturating_sub(1); + if needs_height_measure(app, last, true, active_turn_assistant, true) { + measure_message_height_at(app, base, active_turn_assistant, width, last, &mut stats); + } + } + + let mut budget = RemeasureBudget::new(viewport_height); + while app.viewport.remeasure_active() && !budget.exhausted() { + let Some(i) = app.viewport.next_remeasure_index(msg_count) else { + break; + }; + if (visible_start..=visible_end).contains(&i) { + continue; + } + let is_last = i + 1 == msg_count; + if !needs_height_measure(app, i, is_last, active_turn_assistant, is_streaming) { + stats.reused_msgs += 1; + continue; + } + let measured_lines_before = stats.measured_lines; + measure_message_height_at(app, base, active_turn_assistant, width, i, &mut stats); + budget.consume(stats.measured_lines.saturating_sub(measured_lines_before)); + } + + app.viewport.finalize_remeasure_if_clean(); + stats +} + +fn needs_height_measure( + app: &App, + idx: usize, + is_last: bool, + active_turn_assistant: Option, + is_streaming: bool, +) -> bool { + ((is_last || active_turn_assistant == Some(idx)) && is_streaming) + || !app.viewport.message_height_is_current(idx) +} + +#[allow(clippy::too_many_arguments)] +fn measure_message_height_at( + app: &mut App, + base: SpinnerState, + active_turn_assistant: Option, + width: u16, + idx: usize, + stats: &mut HeightUpdateStats, +) { + let msg_count = app.messages.len(); + let is_last_message = idx + 1 == msg_count; + let sp = msg_spinner(base, idx, active_turn_assistant, &app.messages[idx]); + let (h, rendered_lines) = measure_message_height( + &mut app.messages[idx], + &sp, + width, + app.viewport.layout_generation, + app.tools_collapsed, + !is_last_message, + ); + app.sync_render_cache_message(idx); + stats.measured_msgs += 1; + stats.measured_lines += rendered_lines; + app.viewport.set_message_height(idx, h); + app.viewport.mark_message_height_measured(idx); +} + +/// Measure message height using ground truth: render the message into a scratch +/// buffer and call `Paragraph::line_count(width)`. +/// +/// This uses the exact same code path as actual rendering (`render_message()`), +/// so heights can never diverge from what appears on screen. The scratch vec is +/// temporary and discarded after measurement. Block-level caches are still +/// populated as a side effect (via `render_text_cached` / `render_tool_call_cached`), +/// so completed blocks remain O(1) on subsequent calls. +fn measure_message_height( + msg: &mut crate::app::ChatMessage, + spinner: &SpinnerState, + width: u16, + layout_generation: u64, + tools_collapsed: bool, + include_trailing_separator: bool, +) -> (usize, usize) { + let _t = crate::perf::start_with("chat::measure_msg", "blocks", msg.blocks.len()); + let (h, wrapped_lines) = + message::measure_message_height_cached_with_tools_collapsed_and_separator( + msg, + spinner, + width, + layout_generation, + tools_collapsed, + include_trailing_separator, + ); + crate::perf::mark_with("chat::measure_msg_wrapped_lines", "lines", wrapped_lines); + (h, wrapped_lines) +} + +fn build_base_spinner(app: &App) -> SpinnerState { + let show_subagent_thinking = app.should_show_subagent_thinking(Instant::now()); + SpinnerState { + frame: app.spinner_frame, + is_active_turn_assistant: false, + show_empty_thinking: matches!(app.status, AppStatus::Thinking | AppStatus::Running), + show_thinking: matches!(app.status, AppStatus::Thinking), + show_subagent_thinking, + show_compacting: app.is_compacting, + } +} + +fn sync_chat_layout(app: &mut App, area: Rect, base_spinner: SpinnerState) -> usize { + let width = area.width; + let viewport_height = usize::from(area.height); + + { + let _t = app.perf.as_ref().map(|p| p.start("chat::on_frame")); + if app.viewport.on_frame(width, area.height).resized() { + app.cache_metrics.record_resize(); + } + } + let height_stats = update_visual_heights(app, base_spinner, width, viewport_height); + crate::perf::mark_with( + "chat::update_heights_measured_msgs", + "msgs", + height_stats.measured_msgs, + ); + crate::perf::mark_with("chat::update_heights_reused_msgs", "msgs", height_stats.reused_msgs); + crate::perf::mark_with( + "chat::update_heights_measured_lines", + "lines", + height_stats.measured_lines, + ); + + { + let _t = app.perf.as_ref().map(|p| p.start("chat::prefix_sums")); + app.viewport.rebuild_prefix_sums(); + } + if let Some((anchor_idx, anchor_offset)) = app.viewport.ready_scroll_anchor_to_restore() { + app.viewport.restore_scroll_anchor(anchor_idx, anchor_offset); + } + + let content_height = app.viewport.total_message_height(); + crate::perf::mark_with("chat::content_height", "rows", content_height); + crate::perf::mark_with("chat::viewport_height", "rows", viewport_height); + crate::perf::mark_with( + "chat::content_overflow_rows", + "rows", + content_height.saturating_sub(viewport_height), + ); + content_height +} + +#[allow( + clippy::cast_possible_truncation, + clippy::too_many_arguments, + clippy::too_many_lines, + clippy::cast_precision_loss, + clippy::cast_sign_loss +)] +fn build_scrolled_render_data( + app: &mut App, + base: SpinnerState, + width: u16, + content_height: usize, + viewport_height: usize, +) -> ScrolledRenderData { + let vp = &mut app.viewport; + let reduced_motion = app.config.prefers_reduced_motion_effective(); + let max_scroll = content_height.saturating_sub(viewport_height); + if vp.auto_scroll { + vp.scroll_target = max_scroll; + // Auto-scroll should stay pinned to the latest content without easing lag. + vp.scroll_pos = vp.scroll_target as f32; + } + vp.scroll_target = vp.scroll_target.min(max_scroll); + + if !vp.auto_scroll { + let target = vp.scroll_target as f32; + let delta = target - vp.scroll_pos; + if reduced_motion || delta.abs() < 0.01 { + vp.scroll_pos = target; + } else { + vp.scroll_pos += delta * 0.3; + } + } + vp.scroll_offset = vp.scroll_pos.round() as usize; + clamp_scroll_to_content(vp, max_scroll, reduced_motion); + + let scroll_offset = vp.scroll_offset; + crate::perf::mark_with("chat::max_scroll", "rows", max_scroll); + crate::perf::mark_with("chat::scroll_offset", "rows", scroll_offset); + + let mut all_lines = Vec::new(); + let stats = { + let _t = app + .perf + .as_ref() + .map(|p| p.start_with("chat::render_msgs", "msgs", app.messages.len())); + render_culled_messages(app, base, width, scroll_offset, viewport_height, &mut all_lines) + }; + crate::perf::mark_with("chat::render_scrolled_lines", "lines", all_lines.len()); + crate::perf::mark_with("chat::render_scrolled_msgs", "msgs", stats.rendered_msgs); + crate::perf::mark_with("chat::render_scrolled_first_visible", "idx", stats.first_visible); + crate::perf::mark_with("chat::render_scrolled_start", "idx", stats.render_start); + + let paragraph = { + let _t = app + .perf + .as_ref() + .map(|p| p.start_with("chat::paragraph_build", "lines", all_lines.len())); + Paragraph::new(Text::from(all_lines)).wrap(Wrap { trim: false }) + }; + + ScrolledRenderData { paragraph, stats, max_scroll, scroll_offset } +} + +/// Long content: smooth scroll + viewport culling. +#[allow( + clippy::cast_possible_truncation, + clippy::too_many_arguments, + clippy::too_many_lines, + clippy::cast_precision_loss, + clippy::cast_sign_loss +)] +fn render_scrolled( + frame: &mut Frame, + area: Rect, + app: &mut App, + base: SpinnerState, + width: u16, + content_height: usize, + viewport_height: usize, +) { + let _t = app.perf.as_ref().map(|p| p.start("chat::render_scrolled")); + let render_data = build_scrolled_render_data(app, base, width, content_height, viewport_height); + let pinned_to_bottom = render_data.scroll_offset == render_data.max_scroll; + if tracing::enabled!(tracing::Level::DEBUG) { + let last_message_idx = app.messages.len().checked_sub(1); + let last_message_height = last_message_idx.map(|idx| app.viewport.message_height(idx)); + tracing::debug!( + "RENDER_SCROLLED: auto_scroll={} pinned_to_bottom={} scroll_target={} scroll_pos={:.2} \ + scroll_offset={} max_scroll={} first_visible={} render_start={} local_scroll={} \ + rendered_msgs={} last_rendered_idx={:?} rendered_line_count={} last_message_idx={:?} \ + last_message_height={:?}", + app.viewport.auto_scroll, + pinned_to_bottom, + app.viewport.scroll_target, + app.viewport.scroll_pos, + render_data.scroll_offset, + render_data.max_scroll, + render_data.stats.first_visible, + render_data.stats.render_start, + render_data.stats.local_scroll, + render_data.stats.rendered_msgs, + render_data.stats.last_rendered_idx, + render_data.stats.rendered_line_count, + last_message_idx, + last_message_height, + ); + } + if tracing::enabled!(tracing::Level::TRACE) { + let visible_preview = render_lines_from_paragraph( + &render_data.paragraph, + area, + render_data.stats.local_scroll, + ); + tracing::trace!( + "RENDER_VISIBLE_PREVIEW: bottom_lines={:?}", + preview_tail_lines(&visible_preview, 5), + ); + } + + app.rendered_chat_area = area; + if chat_selection_snapshot_needed(app.selection) { + let _t = app.perf.as_ref().map(|p| p.start("chat::selection_capture")); + app.rendered_chat_lines = render_lines_from_paragraph( + &render_data.paragraph, + area, + render_data.stats.local_scroll, + ); + } + { + let _t = app + .perf + .as_ref() + .map(|p| p.start_with("chat::render_widget", "scroll", render_data.stats.local_scroll)); + frame.render_widget( + render_data + .paragraph + .scroll((paragraph_scroll_offset(render_data.stats.local_scroll), 0)), + area, + ); + } +} + +pub(super) fn refresh_selection_snapshot(app: &mut App) { + if !chat_selection_snapshot_needed(app.selection) { + return; + } + + let area = app.rendered_chat_area; + if area.width == 0 || area.height == 0 { + return; + } + + let base_spinner = build_base_spinner(app); + let content_height = sync_chat_layout(app, area, base_spinner); + let render_data = build_scrolled_render_data( + app, + base_spinner, + area.width, + content_height, + usize::from(area.height), + ); + app.rendered_chat_area = area; + app.rendered_chat_lines = + render_lines_from_paragraph(&render_data.paragraph, area, render_data.stats.local_scroll); +} + +#[must_use] +fn chat_selection_snapshot_needed(selection: Option) -> bool { + selection.is_some_and(|selection| selection.kind == SelectionKind::Chat) +} + +#[must_use] +fn paragraph_scroll_offset(scroll_offset: usize) -> u16 { + u16::try_from(scroll_offset).unwrap_or_else(|_| { + tracing::warn!( + scroll_offset, + max_scroll = u16::MAX, + "chat paragraph scroll exceeds ratatui u16 boundary; clamping local paragraph scroll" + ); + u16::MAX + }) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] +fn clamp_scroll_to_content( + viewport: &mut crate::app::ChatViewport, + max_scroll: usize, + reduced_motion: bool, +) { + viewport.scroll_target = viewport.scroll_target.min(max_scroll); + + // Shrinks can leave the smoothed scroll position beyond new content end. + // Ease it back toward the valid bound while keeping rendered offset clamped. + let max_scroll_f = max_scroll as f32; + if viewport.scroll_pos > max_scroll_f { + if reduced_motion { + viewport.scroll_pos = max_scroll_f; + } else { + let overshoot = viewport.scroll_pos - max_scroll_f; + viewport.scroll_pos = max_scroll_f + overshoot * OVERSCROLL_CLAMP_EASE; + if (viewport.scroll_pos - max_scroll_f).abs() < SCROLLBAR_EASE_EPSILON { + viewport.scroll_pos = max_scroll_f; + } + } + } + + viewport.scroll_offset = (viewport.scroll_pos.round() as usize).min(max_scroll); + if viewport.scroll_offset >= max_scroll { + viewport.auto_scroll = true; + } +} + +/// Compute overlay scrollbar geometry for a single-column track. +/// +/// Returns None when content fits in the viewport. +#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn compute_scrollbar_geometry( + content_height: usize, + viewport_height: usize, + scroll_pos: f32, +) -> Option { + if viewport_height == 0 || content_height <= viewport_height { + return None; + } + let max_scroll = content_height.saturating_sub(viewport_height) as f32; + let thumb_size = viewport_height + .saturating_mul(viewport_height) + .checked_div(content_height) + .unwrap_or(0) + .max(SCROLLBAR_MIN_THUMB_HEIGHT) + .min(viewport_height); + let track_space = viewport_height.saturating_sub(thumb_size) as f32; + let thumb_top = if max_scroll <= f32::EPSILON || track_space <= 0.0 { + 0 + } else { + ((scroll_pos.clamp(0.0, max_scroll) / max_scroll) * track_space).round() as usize + }; + Some(ScrollbarGeometry { thumb_top, thumb_size }) +} + +fn ease_value(current: &mut f32, target: f32, factor: f32) { + let delta = target - *current; + if delta.abs() < SCROLLBAR_EASE_EPSILON { + *current = target; + } else { + *current += delta * factor; + } +} + +#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn smooth_scrollbar_geometry( + viewport: &mut crate::app::ChatViewport, + target: ScrollbarGeometry, + viewport_height: usize, + reduced_motion: bool, +) -> ScrollbarGeometry { + let target_top = target.thumb_top as f32; + let target_size = target.thumb_size as f32; + + if reduced_motion || viewport.scrollbar_thumb_size <= 0.0 { + viewport.scrollbar_thumb_top = target_top; + viewport.scrollbar_thumb_size = target_size; + } else { + ease_value(&mut viewport.scrollbar_thumb_top, target_top, SCROLLBAR_TOP_EASE); + ease_value(&mut viewport.scrollbar_thumb_size, target_size, SCROLLBAR_SIZE_EASE); + } + + let mut thumb_size = viewport.scrollbar_thumb_size.round() as usize; + thumb_size = thumb_size.max(SCROLLBAR_MIN_THUMB_HEIGHT).min(viewport_height); + let max_top = viewport_height.saturating_sub(thumb_size); + let thumb_top = viewport.scrollbar_thumb_top.round().clamp(0.0, max_top as f32) as usize; + + ScrollbarGeometry { thumb_top, thumb_size } +} +#[allow(clippy::cast_possible_truncation)] +fn render_scrollbar_overlay( + frame: &mut Frame, + viewport: &mut crate::app::ChatViewport, + reduced_motion: bool, + area: Rect, + content_height: usize, + viewport_height: usize, +) { + let Some(target) = + compute_scrollbar_geometry(content_height, viewport_height, viewport.scroll_pos) + else { + viewport.scrollbar_thumb_top = 0.0; + viewport.scrollbar_thumb_size = 0.0; + return; + }; + if area.width == 0 || area.height == 0 { + return; + } + let geometry = smooth_scrollbar_geometry(viewport, target, viewport_height, reduced_motion); + let rail_style = Style::default().add_modifier(Modifier::DIM); + let thumb_style = Style::default().fg(theme::ROLE_ASSISTANT); + let rail_x = area.right().saturating_sub(1); + let buf = frame.buffer_mut(); + for row in 0..area.height as usize { + let y = area.y.saturating_add(row as u16); + if let Some(cell) = buf.cell_mut((rail_x, y)) { + cell.set_symbol("\u{2595}"); + cell.set_style(rail_style); + } + } + let thumb_top = geometry.thumb_top.min(area.height.saturating_sub(1) as usize); + let thumb_end = thumb_top.saturating_add(geometry.thumb_size).min(area.height as usize); + for row in thumb_top..thumb_end { + let y = area.y.saturating_add(row as u16); + if let Some(cell) = buf.cell_mut((rail_x, y)) { + cell.set_symbol("\u{2590}"); + cell.set_style(thumb_style); + } + } +} +/// Render only the visible message range into out (viewport culling). +/// Returns the local scroll offset to pass to `Paragraph::scroll()`. +#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)] +fn render_culled_messages( + app: &mut App, + base: SpinnerState, + width: u16, + scroll: usize, + viewport_height: usize, + out: &mut Vec>, +) -> CulledRenderStats { + let msg_count = app.messages.len(); + let active_turn_assistant = app.active_turn_assistant_idx(); + + // O(log n) binary search via prefix sums to find first visible message. + let first_visible = app.viewport.find_first_visible(scroll); + + // Apply margin: render a few extra messages above/below for safety + let render_start = first_visible.saturating_sub(CULLING_MARGIN); + + // O(1) cumulative height lookup via prefix sums + let height_before_start = app.viewport.cumulative_height_before(render_start); + + // Render messages from render_start onward, stopping once the exact wrapped + // height in the output buffer covers the viewport plus a small overscan. + let mut structural_skip = scroll.saturating_sub(height_before_start); + let rows_needed = structural_skip + viewport_height + CULLING_OVERSCAN_ROWS; + crate::perf::mark_with("chat::cull_lines_needed", "lines", rows_needed); + let mut rendered_msgs = 0usize; + let mut local_scroll = 0usize; + let mut rendered_rows = 0usize; + let mut last_rendered_idx = None; + for i in render_start..msg_count { + let sp = msg_spinner(base, i, active_turn_assistant, &app.messages[i]); + let before = out.len(); + let message_height = app.viewport.message_height(i); + if structural_skip > 0 { + let remaining_skip = message::render_message_from_offset_internal( + &mut app.messages[i], + &sp, + width, + app.viewport.layout_generation, + message::MessageRenderOptions { + tools_collapsed: app.tools_collapsed, + include_trailing_separator: i + 1 != msg_count, + }, + structural_skip, + out, + ); + let structural_rows_skipped = structural_skip.saturating_sub(remaining_skip); + rendered_rows = rendered_rows + .saturating_add(message_height.saturating_sub(structural_rows_skipped)); + local_scroll = remaining_skip; + structural_skip = 0; + } else { + message::render_message_with_tools_collapsed_and_separator( + &mut app.messages[i], + &sp, + width, + app.tools_collapsed, + i + 1 != msg_count, + out, + ); + rendered_rows = rendered_rows.saturating_add(message_height); + } + app.sync_render_cache_message(i); + if out.len() > before { + rendered_msgs += 1; + last_rendered_idx = Some(i); + } + if rendered_rows > rows_needed { + break; + } + } + + let stats = CulledRenderStats { + local_scroll, + first_visible, + render_start, + rendered_msgs, + last_rendered_idx, + rendered_line_count: out.len(), + }; + if tracing::enabled!(tracing::Level::DEBUG) { + tracing::debug!( + "RENDER_CULLED: scroll={} viewport_height={} height_before_start={} lines_needed={} \ + first_visible={} render_start={} local_scroll={} rendered_msgs={} last_rendered_idx={:?} \ + rendered_line_count={}", + scroll, + viewport_height, + height_before_start, + rows_needed, + stats.first_visible, + stats.render_start, + stats.local_scroll, + stats.rendered_msgs, + stats.last_rendered_idx, + stats.rendered_line_count, + ); + } + stats +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] +pub fn render(frame: &mut Frame, area: Rect, app: &mut App) { + let _t = app.perf.as_ref().map(|p| p.start("chat::render")); + crate::perf::mark_with("chat::message_count", "msgs", app.messages.len()); + let width = area.width; + let viewport_height = area.height as usize; + let base_spinner = build_base_spinner(app); + let content_height = sync_chat_layout(app, area, base_spinner); + + tracing::trace!( + "RENDER: width={}, content_height={}, viewport_height={}, scroll_target={}, auto_scroll={}", + width, + content_height, + viewport_height, + app.viewport.scroll_target, + app.viewport.auto_scroll + ); + + if content_height <= viewport_height { + crate::perf::mark_with("chat::path_short", "active", 1); + } else { + crate::perf::mark_with("chat::path_scrolled", "active", 1); + } + + render_scrolled(frame, area, app, base_spinner, width, content_height, viewport_height); + + if let Some(sel) = app.selection + && sel.kind == SelectionKind::Chat + { + frame.render_widget(SelectionOverlay { selection: sel }, app.rendered_chat_area); + } + + render_scrollbar_overlay( + frame, + &mut app.viewport, + app.config.prefers_reduced_motion_effective(), + area, + content_height, + viewport_height, + ); + + enforce_and_emit_cache_metrics(app); +} + +#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn enforce_and_emit_cache_metrics(app: &mut App) { + let budget_stats = app.enforce_render_cache_budget(); + crate::perf::mark_with("cache::bytes_before", "bytes", budget_stats.total_before_bytes); + crate::perf::mark_with("cache::bytes_after", "bytes", budget_stats.total_after_bytes); + crate::perf::mark_with("cache::protected_bytes", "bytes", budget_stats.protected_bytes); + crate::perf::mark_with("cache::evicted_bytes", "bytes", budget_stats.evicted_bytes); + crate::perf::mark_with("cache::evicted_blocks", "count", budget_stats.evicted_blocks); + + // -- Accumulate and conditionally log render cache metrics -- + let should_log = + app.cache_metrics.record_render_enforcement(&budget_stats, &app.render_cache_budget); + + let render_utilization_pct = if app.render_cache_budget.max_bytes > 0 { + (app.render_cache_budget.last_total_bytes as f32 / app.render_cache_budget.max_bytes as f32) + * 100.0 + } else { + 0.0 + }; + let history_utilization_pct = if app.history_retention.max_bytes > 0 { + (app.history_retention_stats.total_after_bytes as f32 + / app.history_retention.max_bytes as f32) + * 100.0 + } else { + 0.0 + }; + + if let Some(warn_kind) = app.cache_metrics.check_warn_condition( + render_utilization_pct, + history_utilization_pct, + budget_stats.evicted_blocks, + ) { + cache_metrics::emit_cache_warning(&warn_kind); + } + + if should_log { + let entry_count = count_populated_cache_slots(&app.messages); + let snap = cache_metrics::build_snapshot( + &app.render_cache_budget, + &app.history_retention_stats, + app.history_retention, + &app.cache_metrics, + &app.viewport, + entry_count, + budget_stats.evicted_blocks, + 0, + budget_stats.protected_bytes, + ); + cache_metrics::emit_render_metrics(&snap); + + crate::perf::mark_with("cache::entry_count", "count", entry_count); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + crate::perf::mark_with( + "cache::utilization_pct_x10", + "pct", + (snap.render_utilization_pct * 10.0) as usize, + ); + crate::perf::mark_with("cache::peak_bytes", "bytes", snap.render_peak_bytes); + } +} + +/// Count cache slots with non-zero cached bytes across all message blocks. +/// +/// Only called on log cadence (~every 60 frames), not per-frame. +fn count_populated_cache_slots(messages: &[crate::app::ChatMessage]) -> usize { + messages + .iter() + .flat_map(|m| m.blocks.iter()) + .filter(|block| match block { + MessageBlock::Text(block) => block.cache.cached_bytes() > 0, + MessageBlock::Welcome(w) => w.cache.cached_bytes() > 0, + MessageBlock::ToolCall(tc) => tc.cache.cached_bytes() > 0, + }) + .count() +} + +struct SelectionOverlay { + selection: SelectionState, +} + +impl Widget for SelectionOverlay { + #[allow(clippy::cast_possible_truncation)] + fn render(self, area: Rect, buf: &mut Buffer) { + let (start, end) = + crate::app::normalize_selection(self.selection.start, self.selection.end); + for row in start.row..=end.row { + let y = area.y.saturating_add(row as u16); + if y >= area.bottom() { + break; + } + let row_start = if row == start.row { start.col } else { 0 }; + let row_end = if row == end.row { end.col } else { area.width as usize }; + for col in row_start..row_end { + let x = area.x.saturating_add(col as u16); + if x >= area.right() { + break; + } + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_style(cell.style().add_modifier(Modifier::REVERSED)); + } + } + } + } +} + +#[allow(clippy::cast_possible_truncation)] +fn render_lines_from_paragraph( + paragraph: &Paragraph, + area: Rect, + scroll_offset: usize, +) -> Vec { + let mut buf = Buffer::empty(area); + let widget = paragraph.clone().scroll((paragraph_scroll_offset(scroll_offset), 0)); + widget.render(area, &mut buf); + let mut lines = Vec::with_capacity(area.height as usize); + for y in 0..area.height { + let mut line = String::new(); + for x in 0..area.width { + if let Some(cell) = buf.cell((area.x + x, area.y + y)) { + line.push_str(cell.symbol()); + } + } + lines.push(line.trim_end().to_owned()); + } + lines +} + +fn preview_tail_lines(lines: &[String], count: usize) -> Vec { + lines + .iter() + .rev() + .filter(|line| !line.is_empty()) + .take(count) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect() +} + +#[cfg(test)] +mod tests { + use super::{ + SCROLLBAR_MIN_THUMB_HEIGHT, ScrollbarGeometry, clamp_scroll_to_content, + compute_scrollbar_geometry, paragraph_scroll_offset, render_culled_messages, + render_lines_from_paragraph, render_scrolled, smooth_scrollbar_geometry, + update_visual_heights, + }; + use crate::app::{ + App, AppStatus, ChatMessage, ChatViewport, InvalidationLevel, MessageBlock, MessageRole, + SelectionKind, SelectionPoint, SelectionState, SystemSeverity, TextBlock, + }; + use crate::ui::message::{self, SpinnerState}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use ratatui::text::Text; + use ratatui::widgets::{Paragraph, Wrap}; + + fn assistant_text_message(text: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::Text(TextBlock::from_complete(text))], + usage: None, + } + } + + fn user_message(text: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::User, + blocks: vec![MessageBlock::Text(TextBlock::from_complete(text))], + usage: None, + } + } + + fn system_message(text: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::System(Some(SystemSeverity::Info)), + blocks: vec![MessageBlock::Text(TextBlock::from_complete(text))], + usage: None, + } + } + + fn idle_spinner() -> SpinnerState { + SpinnerState { + frame: 0, + is_active_turn_assistant: false, + show_empty_thinking: false, + show_thinking: false, + show_subagent_thinking: false, + show_compacting: false, + } + } + + fn render_selected_chat_snapshot(app: &mut App, width: u16, height: u16) { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| { + let spinner = idle_spinner(); + let _ = app.viewport.on_frame(width, height); + update_visual_heights(app, spinner, width, usize::from(height)); + app.viewport.rebuild_prefix_sums(); + render_scrolled( + frame, + Rect::new(0, 0, width, height), + app, + spinner, + width, + app.viewport.total_message_height(), + usize::from(height), + ); + }) + .expect("draw"); + } + + #[test] + fn scrollbar_hidden_when_content_fits() { + assert_eq!(compute_scrollbar_geometry(10, 10, 0.0), None); + assert_eq!(compute_scrollbar_geometry(8, 10, 0.0), None); + } + #[test] + fn scrollbar_thumb_positions_are_stable() { + assert_eq!( + compute_scrollbar_geometry(50, 10, 0.0), + Some(ScrollbarGeometry { thumb_top: 0, thumb_size: 2 }) + ); + assert_eq!( + compute_scrollbar_geometry(50, 10, 20.0), + Some(ScrollbarGeometry { thumb_top: 4, thumb_size: 2 }) + ); + assert_eq!( + compute_scrollbar_geometry(50, 10, 40.0), + Some(ScrollbarGeometry { thumb_top: 8, thumb_size: 2 }) + ); + } + #[test] + fn scrollbar_scroll_offset_is_clamped() { + assert_eq!( + compute_scrollbar_geometry(50, 10, 999.0), + Some(ScrollbarGeometry { thumb_top: 8, thumb_size: 2 }) + ); + } + #[test] + fn scrollbar_handles_small_overflow() { + assert_eq!( + compute_scrollbar_geometry(11, 10, 1.0), + Some(ScrollbarGeometry { thumb_top: 1, thumb_size: 9 }) + ); + } + #[test] + fn scrollbar_respects_min_thumb_height() { + assert_eq!( + compute_scrollbar_geometry(10_000, 10, 0.0), + Some(ScrollbarGeometry { thumb_top: 0, thumb_size: SCROLLBAR_MIN_THUMB_HEIGHT }) + ); + } + + #[test] + fn update_visual_heights_remeasures_dirty_non_tail_message() { + let mut app = App::test_default(); + app.status = AppStatus::Ready; + app.messages = + vec![assistant_text_message("short"), assistant_text_message("tail stays unchanged")]; + + let _ = app.viewport.on_frame(12, 8); + let spinner = idle_spinner(); + + update_visual_heights(&mut app, spinner, 12, 8); + let base_h = app.viewport.message_height(0); + assert!(base_h > 0); + + if let Some(MessageBlock::Text(block)) = + app.messages.get_mut(0).and_then(|m| m.blocks.get_mut(0)) + { + let extra = " this now wraps across multiple lines"; + block.text.push_str(extra); + block.markdown.append(extra); + block.cache.invalidate(); + } + app.invalidate_layout(InvalidationLevel::MessagesFrom(0)); + + update_visual_heights(&mut app, spinner, 12, 8); + assert!( + app.viewport.message_height(0) > base_h, + "dirty non-tail message should be remeasured" + ); + } + + #[test] + fn last_message_height_omits_trailing_separator() { + let mut app = App::test_default(); + app.status = AppStatus::Ready; + app.messages = vec![assistant_text_message("hello")]; + + let _ = app.viewport.on_frame(40, 8); + let spinner = idle_spinner(); + + update_visual_heights(&mut app, spinner, 40, 8); + app.viewport.rebuild_prefix_sums(); + + assert_eq!(app.viewport.message_height(0), 2); + assert_eq!(app.viewport.total_message_height(), 2); + } + + #[test] + fn active_turn_assistant_owns_thinking_when_system_message_trails() { + let mut app = App::test_default(); + app.status = AppStatus::Thinking; + app.messages = vec![ + assistant_text_message("older reply"), + user_message("next prompt"), + ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }, + system_message("rate limit warning"), + ]; + app.bind_active_turn_assistant(2); + + assert_eq!(app.active_turn_assistant_idx(), Some(2)); + + let _ = app.viewport.on_frame(40, 8); + let spinner = SpinnerState { show_empty_thinking: true, ..idle_spinner() }; + + update_visual_heights(&mut app, spinner, 40, 8); + app.viewport.rebuild_prefix_sums(); + + assert_eq!( + app.viewport.message_height(2), + 3, + "active assistant should render label + thinking + separator even when a system row trails" + ); + } + + #[test] + fn active_turn_assistant_uses_explicit_owner_without_user_anchor() { + let mut app = App::test_default(); + app.status = AppStatus::Thinking; + app.messages = vec![ + assistant_text_message("older reply"), + ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }, + system_message("status"), + ]; + app.bind_active_turn_assistant(1); + + assert_eq!(app.active_turn_assistant_idx(), Some(1)); + } + + #[test] + fn appending_message_remeasures_previous_tail_separator() { + let mut app = App::test_default(); + app.status = AppStatus::Ready; + app.push_message_tracked(assistant_text_message("first reply")); + + let _ = app.viewport.on_frame(40, 8); + let spinner = idle_spinner(); + + update_visual_heights(&mut app, spinner, 40, 8); + app.viewport.rebuild_prefix_sums(); + assert_eq!(app.viewport.message_height(0), 2); + assert_eq!(app.viewport.total_message_height(), 2); + + app.push_message_tracked(user_message("follow-up")); + + update_visual_heights(&mut app, spinner, 40, 8); + app.viewport.rebuild_prefix_sums(); + assert_eq!(app.viewport.message_height(0), 3); + assert_eq!(app.viewport.message_height(1), 2); + assert_eq!(app.viewport.total_message_height(), 5); + } + + #[test] + fn removing_tail_message_remeasures_new_last_separator() { + let mut app = App::test_default(); + app.status = AppStatus::Ready; + app.push_message_tracked(assistant_text_message("first reply")); + app.push_message_tracked(user_message("follow-up")); + + let _ = app.viewport.on_frame(40, 8); + let spinner = idle_spinner(); + + update_visual_heights(&mut app, spinner, 40, 8); + app.viewport.rebuild_prefix_sums(); + assert_eq!(app.viewport.message_height(0), 3); + assert_eq!(app.viewport.message_height(1), 2); + + let removed = app.remove_message_tracked(1); + assert!(removed.is_some()); + + update_visual_heights(&mut app, spinner, 40, 8); + app.viewport.rebuild_prefix_sums(); + assert_eq!(app.viewport.message_height(0), 2); + assert_eq!(app.viewport.total_message_height(), 2); + } + + #[allow(clippy::cast_precision_loss)] + #[test] + fn resize_remeasure_updates_visible_window_before_far_messages() { + let mut app = App::test_default(); + let text = "This message should wrap after resize and stay expensive enough to measure. " + .repeat(6); + app.messages = (0..32).map(|_| assistant_text_message(&text)).collect(); + + let spinner = idle_spinner(); + + let _ = app.viewport.on_frame(48, 12); + update_visual_heights(&mut app, spinner, 48, 12); + app.viewport.rebuild_prefix_sums(); + let per_message_height = app.viewport.message_height(0); + assert!(per_message_height > 0); + + let visible_rows = per_message_height * 2; + app.viewport.scroll_offset = per_message_height * 15; + app.viewport.scroll_target = app.viewport.scroll_offset; + app.viewport.scroll_pos = app.viewport.scroll_offset as f32; + + assert!(app.viewport.on_frame(18, 12).width_changed); + update_visual_heights(&mut app, spinner, 18, visible_rows); + + assert_eq!(app.viewport.message_heights_width, 0); + assert!(app.viewport.resize_remeasure_active()); + assert!(app.viewport.message_height_is_current(15)); + assert!(app.viewport.message_height_is_current(16)); + assert!(!app.viewport.message_height_is_current(31)); + } + + #[allow(clippy::cast_precision_loss)] + #[test] + fn resize_remeasure_converges_over_multiple_frames() { + let mut app = App::test_default(); + let text = "This message should wrap after resize and stay expensive enough to measure. " + .repeat(6); + app.messages = (0..40).map(|_| assistant_text_message(&text)).collect(); + + let spinner = idle_spinner(); + + let _ = app.viewport.on_frame(48, 12); + update_visual_heights(&mut app, spinner, 48, 12); + app.viewport.rebuild_prefix_sums(); + let per_message_height = app.viewport.message_height(0); + app.viewport.scroll_offset = per_message_height * 12; + app.viewport.scroll_target = app.viewport.scroll_offset; + app.viewport.scroll_pos = app.viewport.scroll_offset as f32; + + assert!(app.viewport.on_frame(18, 12).width_changed); + for _ in 0..8 { + update_visual_heights(&mut app, spinner, 18, per_message_height * 2); + app.viewport.rebuild_prefix_sums(); + if !app.viewport.resize_remeasure_active() { + break; + } + } + + assert_eq!(app.viewport.message_heights_width, 18); + assert!(!app.viewport.resize_remeasure_active()); + assert!(app.viewport.message_height_is_current(0)); + assert!(app.viewport.message_height_is_current(39)); + } + + #[allow(clippy::cast_precision_loss)] + #[test] + fn resize_remeasure_does_not_repeat_dirty_suffix_after_measuring_it() { + let mut app = App::test_default(); + let text = "This message should wrap after resize and stay expensive enough to measure. " + .repeat(6); + app.messages = (0..8).map(|_| assistant_text_message(&text)).collect(); + + let spinner = idle_spinner(); + + let _ = app.viewport.on_frame(48, 12); + update_visual_heights(&mut app, spinner, 48, 12); + app.viewport.rebuild_prefix_sums(); + let per_message_height = app.viewport.message_height(0); + app.viewport.scroll_offset = per_message_height * 2; + app.viewport.scroll_target = app.viewport.scroll_offset; + app.viewport.scroll_pos = app.viewport.scroll_offset as f32; + + assert!(app.viewport.on_frame(18, 12).width_changed); + app.invalidate_layout(InvalidationLevel::MessagesFrom(0)); + + let first = update_visual_heights(&mut app, spinner, 18, per_message_height * 2); + app.viewport.rebuild_prefix_sums(); + let second = update_visual_heights(&mut app, spinner, 18, per_message_height * 2); + + assert!(first.measured_msgs >= app.messages.len()); + assert_eq!(second.measured_msgs, 0); + assert_eq!(app.viewport.message_heights_width, 18); + } + + #[test] + fn render_culled_messages_matches_full_render_when_scrolled_inside_message() { + let mut app = App::test_default(); + let text = (0..160).map(|i| format!("line {i:03}")).collect::>().join("\n"); + app.messages = vec![assistant_text_message(&text)]; + let width = 24u16; + let viewport_height_u16 = 8u16; + let viewport_height = usize::from(viewport_height_u16); + let area = Rect::new(0, 0, width, viewport_height_u16); + let spinner = idle_spinner(); + + let _ = app.viewport.on_frame(width, viewport_height_u16); + update_visual_heights(&mut app, spinner, width, viewport_height); + app.viewport.rebuild_prefix_sums(); + + let scroll = 60; + let mut full_lines = Vec::new(); + message::render_message_with_tools_collapsed_and_separator( + &mut app.messages[0], + &spinner, + width, + app.tools_collapsed, + false, + &mut full_lines, + ); + let full_preview = render_lines_from_paragraph( + &Paragraph::new(Text::from(full_lines.clone())).wrap(Wrap { trim: false }), + area, + scroll, + ); + + let mut culled_lines = Vec::new(); + let stats = render_culled_messages( + &mut app, + spinner, + width, + scroll, + viewport_height, + &mut culled_lines, + ); + let culled_preview = render_lines_from_paragraph( + &Paragraph::new(Text::from(culled_lines.clone())).wrap(Wrap { trim: false }), + area, + stats.local_scroll, + ); + + assert_eq!(culled_preview, full_preview); + assert!(culled_lines.len() < full_lines.len()); + assert_eq!(stats.rendered_msgs, 1); + } + + #[test] + fn render_culled_messages_matches_full_render_when_scrolled_inside_wrapped_role_label() { + let mut app = App::test_default(); + app.messages = vec![user_message("ok")]; + let width = 2u16; + let viewport_height_u16 = 4u16; + let viewport_height = usize::from(viewport_height_u16); + let area = Rect::new(0, 0, width, viewport_height_u16); + let spinner = idle_spinner(); + + let _ = app.viewport.on_frame(width, viewport_height_u16); + update_visual_heights(&mut app, spinner, width, viewport_height); + app.viewport.rebuild_prefix_sums(); + + assert!(app.viewport.message_height(0) >= 3); + + let scroll = 1; + let mut full_lines = Vec::new(); + message::render_message_with_tools_collapsed_and_separator( + &mut app.messages[0], + &spinner, + width, + app.tools_collapsed, + false, + &mut full_lines, + ); + let full_preview = render_lines_from_paragraph( + &Paragraph::new(Text::from(full_lines.clone())).wrap(Wrap { trim: false }), + area, + scroll, + ); + + let mut culled_lines = Vec::new(); + let stats = render_culled_messages( + &mut app, + spinner, + width, + scroll, + viewport_height, + &mut culled_lines, + ); + let culled_preview = render_lines_from_paragraph( + &Paragraph::new(Text::from(culled_lines.clone())).wrap(Wrap { trim: false }), + area, + stats.local_scroll, + ); + + assert_eq!(culled_preview, full_preview); + assert_eq!(stats.rendered_msgs, 1); + assert_eq!(stats.local_scroll, 1); + } + + #[test] + fn render_culled_messages_stops_after_first_wrapped_message_when_viewport_is_covered() { + let mut app = App::test_default(); + let huge_wrapped = "wrap ".repeat(2_000); + app.messages = vec![ + assistant_text_message(&huge_wrapped), + assistant_text_message("this should remain offscreen"), + ]; + let width = 20u16; + let viewport_height_u16 = 8u16; + let viewport_height = usize::from(viewport_height_u16); + let spinner = idle_spinner(); + + let _ = app.viewport.on_frame(width, viewport_height_u16); + update_visual_heights(&mut app, spinner, width, viewport_height); + app.viewport.rebuild_prefix_sums(); + + assert!(app.viewport.message_height(0) > 200); + + let mut culled_lines = Vec::new(); + let stats = render_culled_messages( + &mut app, + spinner, + width, + 40, + viewport_height, + &mut culled_lines, + ); + + assert_eq!(stats.rendered_msgs, 1); + assert_eq!(stats.last_rendered_idx, Some(0)); + } + + #[test] + fn paragraph_scroll_offset_clamps_large_local_scroll_explicitly() { + assert_eq!(paragraph_scroll_offset(42), 42); + assert_eq!(paragraph_scroll_offset(usize::from(u16::MAX) + 123), u16::MAX); + } + + #[test] + fn chat_selection_snapshot_refreshes_without_dragging_after_streaming_change() { + let mut app = App::test_default(); + app.status = AppStatus::Running; + app.messages = vec![assistant_text_message("hello")]; + app.bind_active_turn_assistant(0); + app.selection = Some(SelectionState { + kind: SelectionKind::Chat, + start: SelectionPoint { row: 0, col: 0 }, + end: SelectionPoint { row: 0, col: 5 }, + dragging: false, + }); + + render_selected_chat_snapshot(&mut app, 20, 6); + let first_snapshot = app.rendered_chat_lines.clone(); + assert!(!first_snapshot.is_empty()); + + if let Some(MessageBlock::Text(block)) = + app.messages.get_mut(0).and_then(|message| message.blocks.get_mut(0)) + { + block.text.push_str("\nworld"); + block.markdown.append("\nworld"); + block.cache.invalidate(); + } + app.invalidate_layout(InvalidationLevel::MessageChanged(0)); + + render_selected_chat_snapshot(&mut app, 20, 6); + + assert_ne!(app.rendered_chat_lines, first_snapshot); + assert!(app.rendered_chat_lines.iter().any(|line| line.contains("world"))); + } + + #[test] + fn clamp_scroll_to_content_snaps_overscroll_after_shrink() { + let mut viewport = ChatViewport::new(); + viewport.auto_scroll = false; + viewport.scroll_target = 120; + viewport.scroll_pos = 120.0; + viewport.scroll_offset = 120; + + clamp_scroll_to_content(&mut viewport, 40, false); + + assert!(viewport.auto_scroll); + assert_eq!(viewport.scroll_target, 40); + assert!(viewport.scroll_pos > 40.0); + assert!(viewport.scroll_pos < 120.0); + assert_eq!(viewport.scroll_offset, 40); + } + + #[test] + fn clamp_scroll_to_content_preserves_in_range_scroll() { + let mut viewport = ChatViewport::new(); + viewport.auto_scroll = false; + viewport.scroll_target = 20; + viewport.scroll_pos = 20.0; + viewport.scroll_offset = 20; + + clamp_scroll_to_content(&mut viewport, 40, false); + + assert!(!viewport.auto_scroll); + assert_eq!(viewport.scroll_target, 20); + assert!((viewport.scroll_pos - 20.0).abs() < f32::EPSILON); + assert_eq!(viewport.scroll_offset, 20); + } + + #[test] + fn clamp_scroll_to_content_settles_to_max_over_frames() { + let mut viewport = ChatViewport::new(); + viewport.auto_scroll = false; + viewport.scroll_target = 120; + viewport.scroll_pos = 120.0; + viewport.scroll_offset = 120; + + for _ in 0..12 { + clamp_scroll_to_content(&mut viewport, 40, false); + } + + assert_eq!(viewport.scroll_target, 40); + assert_eq!(viewport.scroll_offset, 40); + assert!(viewport.scroll_pos >= 40.0); + assert!(viewport.scroll_pos < 40.1); + } + + #[test] + fn clamp_scroll_to_content_snaps_overscroll_when_reduced_motion_enabled() { + let mut viewport = ChatViewport::new(); + viewport.auto_scroll = false; + viewport.scroll_target = 120; + viewport.scroll_pos = 120.0; + viewport.scroll_offset = 120; + + clamp_scroll_to_content(&mut viewport, 40, true); + + assert!(viewport.auto_scroll); + assert_eq!(viewport.scroll_target, 40); + assert!((viewport.scroll_pos - 40.0).abs() < f32::EPSILON); + assert_eq!(viewport.scroll_offset, 40); + } + + #[test] + fn smooth_scrollbar_geometry_snaps_when_reduced_motion_enabled() { + let mut viewport = ChatViewport::new(); + viewport.scrollbar_thumb_top = 2.0; + viewport.scrollbar_thumb_size = 3.0; + + let geometry = smooth_scrollbar_geometry( + &mut viewport, + ScrollbarGeometry { thumb_top: 9, thumb_size: 5 }, + 20, + true, + ); + + assert_eq!(geometry, ScrollbarGeometry { thumb_top: 9, thumb_size: 5 }); + assert!((viewport.scrollbar_thumb_top - 9.0).abs() < f32::EPSILON); + assert!((viewport.scrollbar_thumb_size - 5.0).abs() < f32::EPSILON); + } +} diff --git a/claude-code-rust/src/ui/chat_view.rs b/claude-code-rust/src/ui/chat_view.rs new file mode 100644 index 0000000..5b015e9 --- /dev/null +++ b/claude-code-rust/src/ui/chat_view.rs @@ -0,0 +1,113 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::{autocomplete, chat, footer, help, input, layout, theme, todo}; +use crate::app::App; +use ratatui::Frame; +use ratatui::layout::Rect; +#[cfg(feature = "perf")] +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +pub fn render(frame: &mut Frame, app: &mut App) { + let _t = app.perf.as_ref().map(|p| p.start("ui::render")); + let frame_area = frame.area(); + app.cached_frame_area = frame_area; + crate::perf::mark_with("ui::frame_width", "cols", usize::from(frame_area.width)); + crate::perf::mark_with("ui::frame_height", "rows", usize::from(frame_area.height)); + + let todo_height = { + let _t = app.perf.as_ref().map(|p| p.start("ui::todo_height")); + todo::compute_height(app) + }; + help::sync_geometry_state(app, frame_area.width); + let help_height = { + let _t = app.perf.as_ref().map(|p| p.start("ui::help_height")); + help::compute_height(app, frame_area.width) + }; + let input_visual_lines = { + let _t = app.perf.as_ref().map(|p| p.start("ui::input_visual_lines")); + input::visual_line_count(app, frame_area.width) + }; + let areas = { + let _t = app.perf.as_ref().map(|p| p.start("ui::layout")); + layout::compute(frame_area, input_visual_lines, todo_height, help_height) + }; + + { + let _t = app.perf.as_ref().map(|p| p.start("ui::chat")); + chat::render(frame, areas.body, app); + } + + render_separator(frame, areas.input_sep); + + if areas.todo.height > 0 { + let _t = app.perf.as_ref().map(|p| p.start("ui::todo")); + todo::render(frame, areas.todo, app); + } + + { + let _t = app.perf.as_ref().map(|p| p.start("ui::input")); + input::render(frame, areas.input, app); + } + + if autocomplete::is_active(app) { + let _t = app.perf.as_ref().map(|p| p.start("ui::autocomplete")); + autocomplete::render(frame, areas.input, app); + } + + render_separator(frame, areas.input_bottom_sep); + + if areas.help.height > 0 { + let _t = app.perf.as_ref().map(|p| p.start("ui::help")); + help::render(frame, areas.help, app); + } + + if let Some(footer_area) = areas.footer { + let _t = app.perf.as_ref().map(|p| p.start("ui::footer")); + footer::render(frame, footer_area, app); + } + + render_perf_fps_overlay(frame, frame_area, frame_area.y, app); +} + +fn render_separator(frame: &mut Frame, area: Rect) { + if area.height == 0 { + return; + } + let sep_str = theme::SEPARATOR_CHAR.repeat(area.width as usize); + let line = Line::from(Span::styled(sep_str, Style::default().fg(theme::DIM))); + frame.render_widget(Paragraph::new(line), area); +} + +#[cfg(feature = "perf")] +fn render_perf_fps_overlay(frame: &mut Frame, frame_area: Rect, y: u16, app: &App) { + if app.perf.is_none() || frame_area.height == 0 || y >= frame_area.y + frame_area.height { + return; + } + let Some(fps) = app.frame_fps() else { + return; + }; + + let color = if fps >= 55.0 { + Color::Green + } else if fps >= 45.0 { + Color::Yellow + } else { + Color::Red + }; + let text = format!("[{fps:>5.1} FPS]"); + let width = u16::try_from(text.len()).unwrap_or(frame_area.width).min(frame_area.width); + let x = frame_area.x + frame_area.width.saturating_sub(width); + let area = Rect { x, y, width, height: 1 }; + let line = Line::from(Span::styled( + text, + Style::default().fg(color).add_modifier(ratatui::style::Modifier::BOLD), + )); + frame.render_widget(Paragraph::new(line), area); +} + +#[cfg(not(feature = "perf"))] +fn render_perf_fps_overlay(_frame: &mut Frame, _frame_area: Rect, _y: u16, _app: &App) {} diff --git a/claude-code-rust/src/ui/config.rs b/claude-code-rust/src/ui/config.rs new file mode 100644 index 0000000..1858e8b --- /dev/null +++ b/claude-code-rust/src/ui/config.rs @@ -0,0 +1,1992 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +mod input; +mod mcp; +mod overlay; +mod plugins; +mod settings; +mod status; +mod usage; + +use crate::app::config::{ + OutputStyle, OverlayFocus, language_input_validation_message, model_overlay_options, + supported_effort_levels_for_model, +}; +use crate::app::{App, ConfigTab}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; +use ratatui::style::Color; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use super::theme; +use input::{add_marketplace_example_lines, render_text_input_field}; +use overlay::{ + OverlayChrome, OverlayLayoutSpec, overlay_line_style, render_overlay_header, + render_overlay_separator as shared_render_overlay_separator, render_overlay_shell, +}; + +const SETTINGS_LIMITATION_HINT: &str = "Currently, not all settings are supported by claude-rs. This project uses the official Anthropic Claude Agent SDK, which limits claude-rs implementing all Claude Code settings."; +const MIN_SETTINGS_PANEL_HEIGHT: u16 = 3; + +pub fn render(frame: &mut Frame, app: &mut App) { + let frame_area = frame.area(); + app.cached_frame_area = frame_area; + + let outer = Block::default() + .borders(Borders::ALL) + .title("Config") + .border_style(Style::default().fg(theme::DIM)); + frame.render_widget(outer, frame_area); + + let inner = frame_area.inner(Margin { vertical: 1, horizontal: 1 }); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + render_tab_header(frame, chunks[0], app.config.active_tab); + + match app.config.active_tab { + ConfigTab::Settings => settings::render(frame, chunks[1], app), + ConfigTab::Plugins => plugins::render(frame, chunks[1], app), + ConfigTab::Status => status::render(frame, chunks[1], app), + ConfigTab::Usage => usage::render(frame, chunks[1], app), + ConfigTab::Mcp => mcp::render(frame, chunks[1], app), + } + + if app.config.model_and_effort_overlay().is_some() { + render_model_and_effort_overlay(frame, frame_area, app); + } else if app.config.output_style_overlay().is_some() { + render_output_style_overlay(frame, frame_area, app); + } else if app.config.language_overlay().is_some() { + render_language_overlay(frame, frame_area, app); + } else if app.config.session_rename_overlay().is_some() { + render_session_rename_overlay(frame, frame_area, app); + } else if app.config.installed_plugin_actions_overlay().is_some() { + render_installed_plugin_actions_overlay(frame, frame_area, app); + } else if app.config.plugin_install_overlay().is_some() { + render_plugin_install_overlay(frame, frame_area, app); + } else if app.config.marketplace_actions_overlay().is_some() { + render_marketplace_actions_overlay(frame, frame_area, app); + } else if app.config.add_marketplace_overlay().is_some() { + render_add_marketplace_overlay(frame, frame_area, app); + } else if app.config.mcp_details_overlay().is_some() { + mcp::render_details_overlay(frame, frame_area, app); + } else if app.config.mcp_callback_url_overlay().is_some() { + mcp::render_callback_url_overlay(frame, frame_area, app); + } else if app.config.mcp_auth_redirect_overlay().is_some() { + mcp::render_auth_redirect_overlay(frame, frame_area, app); + } else if app.config.mcp_elicitation_overlay().is_some() { + mcp::render_elicitation_overlay(frame, frame_area, app); + } + + let (message, is_error) = if let Some(error) = app.config.last_error.clone() { + (error, true) + } else if let Some(status) = app.config.status_message.clone() { + (status, false) + } else { + (String::new(), false) + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + message, + Style::default().fg(if is_error { theme::STATUS_ERROR } else { theme::DIM }), + ))), + chunks[2], + ); + + let help = config_help_text(app); + frame.render_widget( + Paragraph::new(Line::from(Span::styled(help, Style::default().fg(theme::RUST_ORANGE)))), + chunks[3], + ); +} + +fn config_help_text(app: &App) -> String { + if app.config.overlay.is_some() { + return String::new(); + } + + match app.config.active_tab { + ConfigTab::Settings => { + "Left/Right edit | Space edit | Tab next tab | Shift+Tab prev tab | Enter close | Esc close" + .to_owned() + } + ConfigTab::Plugins => { + if crate::app::plugins::search_enabled(app.plugins.active_tab) { + if app.plugins.search_focused { + "Left/Right switch list | Down list | Type search | Backspace erase | Del clear | Tab next tab | Shift+Tab prev tab | Enter close | Esc close".to_owned() + } else if matches!( + app.plugins.active_tab, + crate::app::plugins::PluginsViewTab::Installed + | crate::app::plugins::PluginsViewTab::Plugins + ) { + "Left/Right switch list | Up search | Up/Down move | Enter actions | Tab next tab | Shift+Tab prev tab | Esc close".to_owned() + } else { + "Left/Right switch list | Up search | Up/Down move | Tab next tab | Shift+Tab prev tab | Enter close | Esc close".to_owned() + } + } else if matches!( + app.plugins.active_tab, + crate::app::plugins::PluginsViewTab::Marketplace + ) { + "Left/Right switch list | Up/Down move | Enter actions | Tab next tab | Shift+Tab prev tab | Esc close".to_owned() + } else { + "Left/Right switch list | Up/Down move | Tab next tab | Shift+Tab prev tab | Enter close | Esc close".to_owned() + } + } + ConfigTab::Usage => { + "r refresh | Tab next tab | Shift+Tab prev tab | Enter close | Esc close".to_owned() + } + ConfigTab::Mcp => { + "Up/Down select | Enter actions | r refresh | Tab next tab | Shift+Tab prev tab | Esc close" + .to_owned() + } + ConfigTab::Status => { + if app.session_id.is_some() { + "g generate | r rename | Tab next tab | Shift+Tab prev tab | Enter close | Esc close" + .to_owned() + } else { + "Tab next tab | Shift+Tab prev tab | Enter close | Esc close".to_owned() + } + } + } +} + +fn render_model_and_effort_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.model_and_effort_overlay() else { + return; + }; + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 1, + min_height: 1, + width_percent: 90, + height_percent: 84, + preferred_height: u16::MAX, + fullscreen_below: Some((90, 20)), + inner_margin: Margin { vertical: 1, horizontal: 1 }, + }, + OverlayChrome { + title: "Model and Thinking Effort", + subtitle: None, + help: Some("Tab switches model/effort | Enter confirm | Esc cancel"), + }, + ); + let model_lines = model_overlay_lines(app); + let effort_lines = effort_overlay_lines(app); + let (model_height, effort_height) = model_and_effort_section_heights(rendered.body_area.height); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(model_height), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(effort_height), + ]) + .split(rendered.body_area); + + let model_focused = overlay.focus == OverlayFocus::Model; + let effort_focused = overlay.focus == OverlayFocus::Effort; + render_overlay_header(frame, sections[0], "Model", model_focused); + shared_render_overlay_separator(frame, sections[2]); + render_overlay_header(frame, sections[3], "Thinking effort", effort_focused); + + let model_scroll = model_overlay_scroll(app, sections[1].height, sections[1].width); + frame.render_widget( + Paragraph::new(model_lines).scroll((model_scroll, 0)).wrap(Wrap { trim: false }), + sections[1], + ); + + frame.render_widget(Paragraph::new(effort_lines).wrap(Wrap { trim: false }), sections[4]); +} + +fn model_overlay_lines(app: &App) -> Vec> { + let Some(overlay) = app.config.model_and_effort_overlay() else { + return Vec::new(); + }; + let mut lines = model_overlay_options(app) + .into_iter() + .flat_map(|option| { + let selected = option.id == overlay.selected_model; + let marker = if selected { ">" } else { " " }; + let mut lines = vec![model_overlay_title_line( + &option, + marker, + selected, + overlay.focus == OverlayFocus::Model, + )]; + if let Some(description) = option.description { + lines.push(Line::from(Span::styled( + format!(" {description}"), + Style::default().fg(theme::DIM), + ))); + } + lines.push(Line::default()); + lines + }) + .collect::>(); + if !lines.is_empty() { + lines.pop(); + } + lines +} + +fn effort_overlay_lines(app: &App) -> Vec> { + let Some(overlay) = app.config.model_and_effort_overlay() else { + return Vec::new(); + }; + let levels = supported_effort_levels_for_model(app, &overlay.selected_model); + if levels.is_empty() { + return vec![ + Line::from(Span::styled( + " Thinking effort is not available for the selected model.", + Style::default().fg(theme::DIM), + )), + Line::default(), + Line::from(Span::styled( + format!(" Saved value: {}", overlay.selected_effort.label()), + Style::default().fg(Color::White), + )), + ]; + } + let mut lines = levels + .into_iter() + .flat_map(|level| { + let selected = level == overlay.selected_effort; + vec![ + Line::from(Span::styled( + format!("{} {}", if selected { ">" } else { " " }, level.label()), + overlay_line_style(selected, overlay.focus == OverlayFocus::Effort), + )), + Line::from(Span::styled( + format!(" {}", level.description()), + Style::default().fg(theme::DIM), + )), + Line::default(), + ] + }) + .collect::>(); + if !lines.is_empty() { + lines.pop(); + } + lines +} + +fn render_output_style_overlay(frame: &mut Frame, area: Rect, app: &App) { + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 72, + min_height: 8, + width_percent: 84, + height_percent: 80, + preferred_height: 14, + fullscreen_below: Some((72, 16)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Preferred output style", + subtitle: Some("This changes how Claude Code communicates with you"), + help: Some("Enter confirm | Esc cancel"), + }, + ); + frame.render_widget( + Paragraph::new(output_style_overlay_lines(app)).wrap(Wrap { trim: false }), + rendered.body_area, + ); +} + +fn render_language_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.language_overlay() else { + return; + }; + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 56, + min_height: 8, + width_percent: 72, + height_percent: 48, + preferred_height: 10, + fullscreen_below: Some((56, 14)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Language", + subtitle: Some("Free-text prompt language for Claude sessions"), + help: Some("Enter confirm | Esc cancel"), + }, + ); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(rendered.body_area); + + render_text_input_field( + frame, + sections[0], + &overlay.draft, + overlay.cursor, + "e.g. en, Greek, Japanese, Pirate", + ); + + let validation = language_input_validation_message(&overlay.draft); + let (message, style) = match validation { + Some(message) => (message, Style::default().fg(theme::STATUS_ERROR)), + None => ( + "Examples: en, Greek, Japanese, Klingon, Pirate. Stored as prompt guidance, not UI language.", + Style::default().fg(theme::DIM), + ), + }; + frame.render_widget(Paragraph::new(Line::from(Span::styled(message, style))), sections[1]); +} + +fn render_session_rename_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.session_rename_overlay() else { + return; + }; + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 56, + min_height: 8, + width_percent: 72, + height_percent: 48, + preferred_height: 10, + fullscreen_below: Some((56, 14)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Rename session", + subtitle: Some("Set a custom title for the current session"), + help: Some("Enter confirm | Esc cancel"), + }, + ); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(rendered.body_area); + + render_text_input_field( + frame, + sections[0], + &overlay.draft, + overlay.cursor, + "Custom session name", + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Leave the field empty to clear the custom session name.", + Style::default().fg(theme::DIM), + ))), + sections[1], + ); +} + +fn render_installed_plugin_actions_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.installed_plugin_actions_overlay() else { + return; + }; + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 56, + min_height: 10, + width_percent: 70, + height_percent: 62, + preferred_height: 14, + fullscreen_below: Some((56, 16)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Installed plugin", + subtitle: None, + help: Some("Up/Down select | Enter run | Esc cancel"), + }, + ); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(rendered.body_area); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + overlay.title.clone(), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ))), + sections[0], + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + overlay.description.clone(), + Style::default().fg(theme::DIM), + ))) + .wrap(Wrap { trim: false }), + sections[1], + ); + shared_render_overlay_separator(frame, sections[2]); + frame.render_widget( + Paragraph::new(installed_plugin_action_overlay_lines(app)).wrap(Wrap { trim: false }), + sections[3], + ); +} + +fn render_plugin_install_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.plugin_install_overlay() else { + return; + }; + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 56, + min_height: 10, + width_percent: 70, + height_percent: 62, + preferred_height: 14, + fullscreen_below: Some((56, 16)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Install plugin", + subtitle: None, + help: Some("Up/Down select | Enter run | Esc cancel"), + }, + ); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(rendered.body_area); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + overlay.title.clone(), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ))), + sections[0], + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + overlay.description.clone(), + Style::default().fg(theme::DIM), + ))) + .wrap(Wrap { trim: false }), + sections[1], + ); + shared_render_overlay_separator(frame, sections[2]); + frame.render_widget( + Paragraph::new(plugin_install_overlay_lines(app)).wrap(Wrap { trim: false }), + sections[3], + ); +} + +fn render_marketplace_actions_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.marketplace_actions_overlay() else { + return; + }; + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 56, + min_height: 10, + width_percent: 70, + height_percent: 62, + preferred_height: 14, + fullscreen_below: Some((56, 16)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Marketplace", + subtitle: None, + help: Some("Up/Down select | Enter run | Esc cancel"), + }, + ); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(rendered.body_area); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + overlay.title.clone(), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ))), + sections[0], + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + overlay.description.clone(), + Style::default().fg(theme::DIM), + ))) + .wrap(Wrap { trim: false }), + sections[1], + ); + shared_render_overlay_separator(frame, sections[2]); + frame.render_widget( + Paragraph::new(marketplace_action_overlay_lines(app)).wrap(Wrap { trim: false }), + sections[3], + ); +} + +fn render_add_marketplace_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.add_marketplace_overlay() else { + return; + }; + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 60, + min_height: 13, + width_percent: 72, + height_percent: 66, + preferred_height: 15, + fullscreen_below: Some((60, 18)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Add Marketplace", + subtitle: None, + help: Some("Enter add | Esc cancel"), + }, + ); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(5), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ]) + .split(rendered.body_area); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Enter marketplace source:", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ))), + sections[0], + ); + frame.render_widget( + Paragraph::new(add_marketplace_example_lines()).wrap(Wrap { trim: false }), + sections[1], + ); + render_text_input_field( + frame, + sections[3], + &overlay.draft, + overlay.cursor, + "owner/repo or URL", + ); +} + +fn output_style_overlay_lines(app: &App) -> Vec> { + let Some(overlay) = app.config.output_style_overlay() else { + return Vec::new(); + }; + + let mut lines = Vec::new(); + for (index, style) in OutputStyle::ALL.iter().copied().enumerate() { + let selected = style == overlay.selected; + let marker = if selected { ">" } else { " " }; + lines.push(Line::from(vec![ + Span::styled(format!("{marker} {}. ", index + 1), overlay_line_style(selected, true)), + Span::styled(style.label().to_owned(), overlay_line_style(selected, true)), + ])); + lines.push(Line::from(Span::styled( + format!(" {}", style.description()), + Style::default().fg(theme::DIM), + ))); + if index + 1 < OutputStyle::ALL.len() { + lines.push(Line::default()); + } + } + lines +} + +fn installed_plugin_action_overlay_lines(app: &App) -> Vec> { + let Some(overlay) = app.config.installed_plugin_actions_overlay() else { + return Vec::new(); + }; + + let mut lines = Vec::new(); + for (index, action) in overlay.actions.iter().copied().enumerate() { + let selected = index == overlay.selected_index; + lines.push(Line::from(Span::styled( + format!("{} {}", if selected { ">" } else { " " }, action.label()), + overlay_line_style(selected, true), + ))); + if index + 1 < overlay.actions.len() { + lines.push(Line::default()); + } + } + lines +} + +fn plugin_install_overlay_lines(app: &App) -> Vec> { + let Some(overlay) = app.config.plugin_install_overlay() else { + return Vec::new(); + }; + + let mut lines = Vec::new(); + for (index, action) in overlay.actions.iter().copied().enumerate() { + let selected = index == overlay.selected_index; + lines.push(Line::from(Span::styled( + format!("{} {}", if selected { ">" } else { " " }, action.label()), + overlay_line_style(selected, true), + ))); + if index + 1 < overlay.actions.len() { + lines.push(Line::default()); + } + } + lines +} + +fn marketplace_action_overlay_lines(app: &App) -> Vec> { + let Some(overlay) = app.config.marketplace_actions_overlay() else { + return Vec::new(); + }; + + let mut lines = Vec::new(); + for (index, action) in overlay.actions.iter().copied().enumerate() { + let selected = index == overlay.selected_index; + lines.push(Line::from(Span::styled( + format!("{} {}", if selected { ">" } else { " " }, action.label()), + overlay_line_style(selected, true), + ))); + if index + 1 < overlay.actions.len() { + lines.push(Line::default()); + } + } + lines +} + +fn model_and_effort_section_heights(inner_height: u16) -> (u16, u16) { + const CHROME_HEIGHT: u16 = 3; + const DEFAULT_EFFORT_HEIGHT: u16 = 8; + + let content_height = inner_height.saturating_sub(CHROME_HEIGHT); + match content_height { + 0 => (0, 0), + 1 => (1, 0), + _ => { + let effort_height = DEFAULT_EFFORT_HEIGHT.min(content_height.saturating_sub(1)); + let model_height = content_height.saturating_sub(effort_height); + (model_height, effort_height) + } + } +} + +fn model_overlay_scroll(app: &App, viewport_height: u16, viewport_width: u16) -> u16 { + let Some(overlay) = app.config.model_and_effort_overlay() else { + return 0; + }; + let options = model_overlay_options(app); + if options.is_empty() || viewport_height == 0 || viewport_width == 0 { + return 0; + } + + let selected_index = + options.iter().position(|option| option.id == overlay.selected_model).unwrap_or(0); + let selected_start = options + .iter() + .take(selected_index) + .enumerate() + .map(|(index, option)| { + model_overlay_option_height(option, index + 1 == options.len(), viewport_width) + }) + .sum::(); + let selected_height = model_overlay_option_height( + &options[selected_index], + selected_index + 1 == options.len(), + viewport_width, + ); + let viewport_height = usize::from(viewport_height); + + if selected_start + selected_height <= viewport_height { + 0 + } else { + u16::try_from(selected_start + selected_height - viewport_height).unwrap_or(u16::MAX) + } +} + +fn model_overlay_option_height( + option: &crate::app::config::OverlayModelOption, + is_last: bool, + viewport_width: u16, +) -> usize { + let title = model_overlay_title_line(option, " ", false, false); + let mut height = wrapped_text_height(Text::from(vec![title]), viewport_width); + if let Some(description) = option.description.as_deref() { + height += wrapped_text_height( + Text::from(vec![Line::from(Span::styled( + format!(" {description}"), + Style::default().fg(theme::DIM), + ))]), + viewport_width, + ); + } + height + usize::from(!is_last) +} + +struct CapabilityBadge { + label: &'static str, + bg: Color, + fg: Color, +} + +#[cfg(test)] +fn model_overlay_title_text( + option: &crate::app::config::OverlayModelOption, + marker: &str, +) -> String { + let badges = model_capability_badges(option); + let mut title = format!("{marker} {}", option.display_name); + if !badges.is_empty() { + title.push_str(" "); + title.push_str(&badges.into_iter().map(|badge| badge.label).collect::>().join(" ")); + } + title +} + +fn model_overlay_title_line( + option: &crate::app::config::OverlayModelOption, + marker: &str, + selected: bool, + focused: bool, +) -> Line<'static> { + Line::from(model_overlay_title_spans(option, marker, selected, focused)) +} + +fn model_overlay_title_spans( + option: &crate::app::config::OverlayModelOption, + marker: &str, + selected: bool, + focused: bool, +) -> Vec> { + let mut spans = vec![Span::styled( + format!("{marker} {}", option.display_name), + overlay_line_style(selected, focused), + )]; + let badges = model_capability_badges(option); + if badges.is_empty() { + return spans; + } + spans.push(Span::styled(" ", Style::default().fg(theme::DIM))); + for (index, badge) in badges.into_iter().enumerate() { + if index > 0 { + spans.push(Span::styled(" ", Style::default().fg(theme::DIM))); + } + spans.push(Span::styled( + format!(" {} ", badge.label), + Style::default().fg(badge.fg).bg(badge.bg).add_modifier(Modifier::BOLD), + )); + } + spans +} + +fn model_capability_badges( + option: &crate::app::config::OverlayModelOption, +) -> Vec { + let mut badges = Vec::new(); + if option.supports_effort { + badges.push(CapabilityBadge { + label: "Effort", + bg: Color::Rgb(64, 64, 64), + fg: Color::White, + }); + } + if option.supports_adaptive_thinking == Some(true) { + badges.push(CapabilityBadge { + label: "Adaptive thinking", + bg: Color::Rgb(34, 92, 124), + fg: Color::White, + }); + } + if option.supports_fast_mode == Some(true) { + badges.push(CapabilityBadge { + label: "Fast mode", + bg: Color::Rgb(24, 120, 82), + fg: Color::White, + }); + } + if option.supports_auto_mode == Some(true) { + badges.push(CapabilityBadge { + label: "Auto mode", + bg: Color::Rgb(152, 106, 0), + fg: Color::Black, + }); + } + badges +} + +fn wrapped_text_height(text: Text<'static>, viewport_width: u16) -> usize { + Paragraph::new(text).wrap(Wrap { trim: false }).line_count(viewport_width.max(1)).max(1) +} + +fn render_tab_header(frame: &mut Frame, area: Rect, active_tab: ConfigTab) { + let mut spans = Vec::new(); + for (index, tab) in ConfigTab::ALL.iter().copied().enumerate() { + if index > 0 { + spans.push(Span::styled(" | ", Style::default().fg(theme::DIM))); + } + + let style = if tab == active_tab { + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + spans.push(Span::styled(tab.title().to_owned(), style)); + } + + let line = Line::from(spans); + let content_width = u16::try_from(line.width()).unwrap_or(area.width).min(area.width); + let header_area = centered_line_area(area, content_width); + frame.render_widget(Paragraph::new(line), header_area); +} + +fn centered_line_area(area: Rect, content_width: u16) -> Rect { + if content_width >= area.width { + return area; + } + + let offset = area.width.saturating_sub(content_width) / 2; + Rect { x: area.x + offset, y: area.y, width: content_width, height: area.height } +} + +#[cfg(test)] +mod tests { + use super::{ + SETTINGS_LIMITATION_HINT, model_overlay_lines, model_overlay_scroll, + model_overlay_title_line, model_overlay_title_text, + }; + use crate::agent::model::{AvailableModel, EffortLevel}; + use crate::app::App; + use crate::app::config::{ + ConfigOverlayState, LanguageOverlayState, ModelAndEffortOverlayState, OutputStyle, + OutputStyleOverlayState, OverlayFocus, SettingId, setting_specs, + }; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Style; + use ratatui::text::{Line, Span, Text}; + use ratatui::widgets::{Paragraph, Wrap}; + + fn rendered_model_option_height( + option: &crate::app::config::OverlayModelOption, + is_last: bool, + viewport_width: u16, + ) -> usize { + let mut lines = vec![model_overlay_title_line(option, " ", false, false)]; + if let Some(description) = option.description.as_deref() { + lines.push(Line::from(Span::styled( + format!(" {description}"), + Style::default().fg(super::theme::DIM), + ))); + } + if !is_last { + lines.push(Line::default()); + } + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }).line_count(viewport_width) + } + + #[test] + fn model_overlay_scroll_keeps_selected_multiline_model_visible() { + let mut app = App::test_default(); + app.available_models = vec![ + AvailableModel::new("default", "Default") + .description("Opus 4.6") + .supports_effort(true) + .supported_effort_levels(vec![ + EffortLevel::Low, + EffortLevel::Medium, + EffortLevel::High, + ]), + AvailableModel::new("opus-1m", "Opus (1M context)") + .description("Extra usage") + .supports_effort(true) + .supported_effort_levels(vec![ + EffortLevel::Low, + EffortLevel::Medium, + EffortLevel::High, + ]), + AvailableModel::new("sonnet", "Sonnet") + .description("Everyday tasks") + .supports_effort(true) + .supported_effort_levels(vec![ + EffortLevel::Low, + EffortLevel::Medium, + EffortLevel::High, + ]), + AvailableModel::new("haiku", "Haiku").description("Fastest").supports_effort(false), + ]; + app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { + focus: OverlayFocus::Model, + selected_model: "sonnet".to_owned(), + selected_effort: EffortLevel::High, + })); + + assert_eq!(model_overlay_scroll(&app, 6, 40), 5); + } + + #[test] + fn model_overlay_scroll_accounts_for_wrapped_lines() { + let mut app = App::test_default(); + app.available_models = vec![ + AvailableModel::new("default", "Default") + .description("1234567890") + .supports_effort(true) + .supported_effort_levels(vec![ + EffortLevel::Low, + EffortLevel::Medium, + EffortLevel::High, + ]), + AvailableModel::new("haiku", "Haiku").supports_effort(false), + ]; + app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { + focus: OverlayFocus::Model, + selected_model: "haiku".to_owned(), + selected_effort: EffortLevel::Medium, + })); + + assert_eq!(model_overlay_scroll(&app, 4, 10), 2); + } + + #[test] + fn model_overlay_scroll_accounts_for_badge_padding_width() { + let mut app = App::test_default(); + app.available_models = vec![ + AvailableModel::new("sonnet", "Sonnet") + .description("Everyday tasks") + .supports_effort(true) + .supported_effort_levels(vec![EffortLevel::Low, EffortLevel::Medium]) + .supports_adaptive_thinking(Some(true)) + .supports_fast_mode(Some(true)) + .supports_auto_mode(Some(true)), + AvailableModel::new("haiku", "Haiku") + .description("Fastest") + .supports_effort(false) + .supports_fast_mode(Some(true)), + ]; + app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { + focus: OverlayFocus::Model, + selected_model: "haiku".to_owned(), + selected_effort: EffortLevel::Medium, + })); + + let options = crate::app::config::model_overlay_options(&app); + let selected_index = + options.iter().position(|option| option.id == "haiku").expect("selected model index"); + let selected_start = options + .iter() + .take(selected_index) + .enumerate() + .map(|(index, option)| { + rendered_model_option_height(option, index + 1 == options.len(), 18) + }) + .sum::(); + let selected_height = rendered_model_option_height( + &options[selected_index], + selected_index + 1 == options.len(), + 18, + ); + let expected_scroll = selected_start.saturating_add(selected_height).saturating_sub(4); + + assert_eq!( + model_overlay_scroll(&app, 4, 18), + u16::try_from(expected_scroll).unwrap_or(u16::MAX) + ); + assert!(expected_scroll > 0); + } + + #[test] + fn model_overlay_lines_show_positive_capability_badges_only() { + let mut app = App::test_default(); + app.available_models = vec![ + AvailableModel::new("sonnet", "Sonnet") + .description("Everyday tasks") + .supports_effort(true) + .supported_effort_levels(vec![ + EffortLevel::Low, + EffortLevel::Medium, + EffortLevel::High, + ]) + .supports_adaptive_thinking(Some(true)) + .supports_fast_mode(Some(true)) + .supports_auto_mode(Some(true)), + AvailableModel::new("haiku", "Haiku") + .description("Fastest") + .supports_effort(false) + .supports_adaptive_thinking(Some(false)) + .supports_fast_mode(Some(true)) + .supports_auto_mode(None), + ]; + app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { + focus: OverlayFocus::Model, + selected_model: "sonnet".to_owned(), + selected_effort: EffortLevel::High, + })); + + let rendered = + model_overlay_lines(&app).into_iter().map(|line| line.to_string()).collect::>(); + + let sonnet_line = + rendered.iter().find(|line| line.contains("> Sonnet")).expect("sonnet line"); + assert!(sonnet_line.contains("Effort")); + assert!(sonnet_line.contains("Adaptive thinking")); + assert!(sonnet_line.contains("Fast mode")); + assert!(sonnet_line.contains("Auto mode")); + + let haiku_line = rendered.iter().find(|line| line.contains(" Haiku")).expect("haiku line"); + assert!(haiku_line.contains("Fast mode")); + assert!(!haiku_line.contains("Auto mode")); + assert!(rendered.iter().all(|line| !line.contains("no effort") + && !line.contains("adaptive false") + && !line.contains('['))); + } + + #[test] + fn model_overlay_title_text_uses_human_labels_without_divider() { + let title = model_overlay_title_text( + &crate::app::config::OverlayModelOption { + id: "sonnet".to_owned(), + display_name: "Sonnet".to_owned(), + description: None, + supports_effort: true, + supported_effort_levels: vec![EffortLevel::Low, EffortLevel::Medium], + supports_adaptive_thinking: Some(true), + supports_fast_mode: Some(true), + supports_auto_mode: Some(false), + }, + ">", + ); + + assert_eq!(title, "> Sonnet Effort Adaptive thinking Fast mode"); + } + + #[test] + fn output_style_overlay_lists_expected_options() { + let mut app = App::test_default(); + app.config.overlay = Some(ConfigOverlayState::OutputStyle(OutputStyleOverlayState { + selected: OutputStyle::Explanatory, + })); + + let rendered = super::output_style_overlay_lines(&app) + .into_iter() + .map(|line| line.to_string()) + .collect::>(); + + assert!(rendered.iter().any(|line| line.contains("1. Default"))); + assert!(rendered.iter().any(|line| line.contains("2. Explanatory"))); + assert!(rendered.iter().any(|line| line.contains("3. Learning"))); + } + + #[test] + fn language_overlay_input_uses_placeholder_when_empty() { + let line = + super::input::text_input_line("", 0, "e.g. en, Greek, Japanese, Pirate").to_string(); + + assert!(line.contains("e.g. en, Greek, Japanese, Pirate")); + } + + #[test] + fn session_rename_overlay_input_uses_placeholder_when_empty() { + let line = super::input::text_input_line("", 0, "Custom session name").to_string(); + + assert!(line.contains("Custom session name")); + } + + #[test] + fn language_overlay_renders_inline_validation_message() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(120, 30); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.overlay = Some(ConfigOverlayState::Language(LanguageOverlayState { + draft: "E".to_owned(), + cursor: 1, + })); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Language must be at least 2 characters.")); + } + + #[test] + fn output_style_details_do_not_show_unsupported_warning() { + let mut app = App::test_default(); + app.config.selected_setting_index = setting_specs() + .iter() + .position(|spec| spec.id == SettingId::OutputStyle) + .expect("output style row"); + + let rendered = super::settings::setting_detail_lines(&app) + .into_iter() + .map(|line| line.to_string()) + .collect::>(); + + assert!(!rendered.iter().any(|line| line.contains("not supported yet"))); + } + + #[test] + fn compact_settings_layout_triggers_for_small_tuis() { + assert!(super::settings::compact_settings_layout(Rect::new(0, 0, 89, 25))); + assert!(super::settings::compact_settings_layout(Rect::new(0, 0, 100, 19))); + assert!(!super::settings::compact_settings_layout(Rect::new(0, 0, 90, 20))); + } + + #[test] + fn compact_settings_list_does_not_inline_warning_for_supported_output_style() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(80, 16); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.selected_setting_index = setting_specs() + .iter() + .position(|spec| spec.id == SettingId::OutputStyle) + .expect("output style row"); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + + assert!(!rendered.contains("not supported yet")); + } + + #[test] + fn compact_settings_supported_output_style_does_not_render_warning_lines() { + fn buffer_lines(buffer: &Buffer) -> Vec { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect() + } + + let backend = TestBackend::new(42, 20); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.selected_setting_index = setting_specs() + .iter() + .position(|spec| spec.id == SettingId::OutputStyle) + .expect("output style row"); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_lines(terminal.backend().buffer()); + + let warning_lines = rendered + .iter() + .filter(|line| { + line.contains("Warning: not supported yet;") + || line.contains("this setting") + || line.contains("affect") + || line.contains("sessions.") + }) + .count(); + + assert_eq!(warning_lines, 0); + } + + #[test] + fn render_updates_settings_scroll_offset_to_keep_selection_visible() { + let backend = TestBackend::new(80, 16); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.selected_setting_index = setting_specs().len().saturating_sub(1); + app.config.settings_scroll_offset = 0; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + assert!(app.config.settings_scroll_offset > 0); + } + + #[test] + fn normal_layout_renders_settings_limitation_hint() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(180, 30); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + + assert!(rendered.contains("supported by claude-rs")); + assert!(rendered.contains("Anthropic Claude Agent SDK")); + } + + #[test] + fn compact_layout_renders_settings_limitation_hint() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + + assert!(rendered.contains("supported by claude-rs")); + assert!(rendered.contains("Anthropic Claude Agent SDK")); + } + + #[test] + fn settings_limitation_hint_wraps_on_narrow_widths() { + assert_eq!(super::settings::settings_hint_height(200), 1); + assert!(super::settings::settings_hint_height(40) > 1); + assert!( + super::settings::settings_hint_height(20) > super::settings::settings_hint_height(40) + ); + assert_eq!(super::settings::settings_hint_height(0), 0); + assert!(!SETTINGS_LIMITATION_HINT.is_empty()); + } + + #[test] + fn status_tab_renders_session_info() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Status; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Version"), "missing Version"); + assert!(rendered.contains("cwd"), "missing cwd"); + assert!(rendered.contains("Model"), "missing Model"); + } + + #[test] + fn status_tab_help_omits_space_edit() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Status; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(!rendered.contains("Space edit"), "Status tab should not show Space edit"); + assert!(rendered.contains("Tab next tab"), "missing tab navigation hint"); + assert!(rendered.contains("Enter close"), "missing Enter close"); + } + + #[test] + fn usage_tab_help_shows_refresh_hint() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Usage; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("r refresh")); + assert!(rendered.contains("Shift+Tab prev tab")); + } + + #[test] + fn settings_tab_help_shows_edit_keys() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Settings; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Left/Right edit")); + assert!(rendered.contains("Space edit")); + assert!(rendered.contains("Shift+Tab prev tab")); + } + + #[test] + fn plugins_tab_renders_inventory_shell() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.plugins.installed = vec![crate::app::plugins::InstalledPluginEntry { + id: "frontend-design@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "user".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: None, + capability: crate::app::plugins::PluginCapability::Skill, + }]; + app.plugins.marketplace = vec![crate::app::plugins::MarketplaceEntry { + plugin_id: "frontend-design@claude-plugins-official".to_owned(), + name: "frontend-design".to_owned(), + description: Some("Create distinctive interfaces".to_owned()), + marketplace_name: Some("claude-plugins-official".to_owned()), + version: Some("1.0.0".to_owned()), + install_count: Some(42), + source: None, + }]; + app.plugins.marketplaces = vec![crate::app::plugins::MarketplaceSourceEntry { + name: "claude-plugins-official".to_owned(), + source: Some("github".to_owned()), + repo: Some("anthropics/claude-plugins-official".to_owned()), + }]; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Installed (1)")); + assert!(rendered.contains("Plugins (1)")); + assert!(rendered.contains("Marketplace (1)")); + assert!(rendered.contains("Search")); + assert!(rendered.contains("Type to filter this list")); + assert!(rendered.contains("Frontend Design From Claude Plugins Official")); + assert!(rendered.contains("SKILL")); + assert!(rendered.contains("Left/Right switch list")); + } + + #[test] + fn plugins_tab_renders_marketplace_plugin_title_and_plugin_id() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Plugins; + app.plugins.marketplace = vec![crate::app::plugins::MarketplaceEntry { + plugin_id: "frontend-design@claude-plugins-official".to_owned(), + name: "frontend-design".to_owned(), + description: Some("Review UI".to_owned()), + marketplace_name: Some("claude-plugins-official".to_owned()), + version: Some("1.0.0".to_owned()), + install_count: Some(42), + source: None, + }]; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Frontend Design")); + assert!(rendered.contains("Plugin: frontend-design@claude-plugins-official")); + } + + #[test] + fn plugins_tab_groups_relevant_installed_plugins_above_other_projects() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.cwd_raw = "C:\\work\\project-b".to_owned(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.plugins.installed = vec![ + crate::app::plugins::InstalledPluginEntry { + id: "other-local@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "local".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: Some("C:\\work\\project-a".to_owned()), + capability: crate::app::plugins::PluginCapability::Skill, + }, + crate::app::plugins::InstalledPluginEntry { + id: "user-plugin@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "user".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: None, + capability: crate::app::plugins::PluginCapability::Skill, + }, + crate::app::plugins::InstalledPluginEntry { + id: "current-local@claude-plugins-official".to_owned(), + version: Some("1.0.0".to_owned()), + scope: "local".to_owned(), + enabled: true, + installed_at: None, + last_updated: None, + project_path: Some("C:\\work\\project-b".to_owned()), + capability: crate::app::plugins::PluginCapability::Skill, + }, + ]; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + let user_index = + rendered.find("User Plugin From Claude Plugins Official").expect("user plugin"); + let current_index = rendered + .find("Current Local From Claude Plugins Official") + .expect("current project plugin"); + let other_index = rendered + .find("Other Local From Claude Plugins Official") + .expect("other project plugin"); + + assert!(user_index < other_index); + assert!(current_index < other_index); + assert!(rendered.contains("Available here")); + assert!(rendered.contains("Installed elsewhere")); + } + + #[test] + fn plugins_tab_shows_loading_copy_instead_of_empty_state_during_refresh() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.plugins.loading = true; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Loading installed plugins...")); + assert!(!rendered.contains("No installed plugins found.")); + } + + #[test] + fn marketplace_tab_renders_configured_heading_and_add_placeholder() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.plugins.active_tab = crate::app::plugins::PluginsViewTab::Marketplace; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Configured marketplaces")); + assert!(rendered.contains("Add marketplace")); + } + + #[test] + fn installed_plugin_overlay_renders_title_description_and_actions() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.config.overlay = Some(crate::app::config::ConfigOverlayState::InstalledPluginActions( + crate::app::config::InstalledPluginActionOverlayState { + plugin_id: "frontend-design@claude-plugins-official".to_owned(), + title: "Frontend Design From Claude Plugins Official".to_owned(), + description: "Create distinctive interfaces".to_owned(), + scope: "local".to_owned(), + project_path: Some("C:\\work\\project-a".to_owned()), + selected_index: 0, + actions: vec![ + crate::app::config::InstalledPluginActionKind::Disable, + crate::app::config::InstalledPluginActionKind::Update, + crate::app::config::InstalledPluginActionKind::InstallInCurrentProject, + crate::app::config::InstalledPluginActionKind::Uninstall, + ], + }, + )); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Installed plugin")); + assert!(rendered.contains("Frontend Design From Claude Plugins Official")); + assert!(rendered.contains("Create distinctive interfaces")); + assert!(rendered.contains("Install in current project")); + assert!(rendered.contains("Up/Down select")); + } + + #[test] + fn plugin_install_overlay_renders_title_description_and_actions() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.config.overlay = Some(crate::app::config::ConfigOverlayState::PluginInstallActions( + crate::app::config::PluginInstallOverlayState { + plugin_id: "frontend-design@claude-plugins-official".to_owned(), + title: "Frontend Design".to_owned(), + description: "Create distinctive interfaces".to_owned(), + selected_index: 0, + actions: vec![ + crate::app::config::PluginInstallActionKind::User, + crate::app::config::PluginInstallActionKind::Project, + crate::app::config::PluginInstallActionKind::Local, + ], + }, + )); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Install plugin")); + assert!(rendered.contains("Frontend Design")); + assert!(rendered.contains("Create distinctive interfaces")); + assert!(rendered.contains("Install for project")); + assert!(rendered.contains("Up/Down select")); + } + + #[test] + fn marketplace_actions_overlay_renders_title_description_and_actions() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.config.overlay = Some(crate::app::config::ConfigOverlayState::MarketplaceActions( + crate::app::config::MarketplaceActionsOverlayState { + name: "claude-plugins-official".to_owned(), + title: "Claude Plugins Official".to_owned(), + description: "Source: github\nRepo: anthropics/claude-plugins-official".to_owned(), + selected_index: 0, + actions: vec![ + crate::app::config::MarketplaceActionKind::Update, + crate::app::config::MarketplaceActionKind::Remove, + ], + }, + )); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Marketplace")); + assert!(rendered.contains("Claude Plugins Official")); + assert!(rendered.contains("Source: github")); + assert!(rendered.contains("Remove")); + } + + #[test] + fn add_marketplace_overlay_renders_examples() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Plugins; + app.config.overlay = Some(crate::app::config::ConfigOverlayState::AddMarketplace( + crate::app::config::AddMarketplaceOverlayState { draft: String::new(), cursor: 0 }, + )); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Add Marketplace")); + assert!(rendered.contains("Enter marketplace source:")); + assert!(rendered.contains("owner/repo (GitHub)")); + assert!(rendered.contains("Enter add")); + } + + #[test] + fn mcp_details_overlay_renders_selected_server_details() { + use std::collections::BTreeMap; + + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Mcp; + app.config.overlay = Some(crate::app::config::ConfigOverlayState::McpDetails( + crate::app::config::McpDetailsOverlayState { + server_name: "filesystem".to_owned(), + selected_index: 0, + }, + )); + app.mcp.servers = vec![crate::agent::types::McpServerStatus { + name: "filesystem".to_owned(), + status: crate::agent::types::McpServerConnectionStatus::Connected, + server_info: Some(crate::agent::types::McpServerInfo { + name: "Filesystem".to_owned(), + version: "1.2.3".to_owned(), + }), + error: None, + config: Some(crate::agent::types::McpServerStatusConfig::Stdio { + command: "npx".to_owned(), + args: vec!["@modelcontextprotocol/server-filesystem".to_owned()], + env: BTreeMap::new(), + }), + scope: Some("project".to_owned()), + tools: vec![crate::agent::types::McpTool { + name: "read_file".to_owned(), + description: Some("Read a file".to_owned()), + annotations: Some(crate::agent::types::McpToolAnnotations { + read_only: Some(true), + destructive: Some(false), + open_world: Some(false), + }), + }], + }]; + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("filesystem")); + assert!(rendered.contains("project")); + assert!(rendered.contains("stdio")); + assert!(rendered.contains("Reconnect server")); + assert!(rendered.contains("Disable server")); + assert!(rendered.contains("Enter run")); + } + + #[test] + fn status_tab_help_shows_generate_and_rename_when_session_is_active() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.active_tab = crate::app::ConfigTab::Status; + app.session_id = Some(crate::agent::model::SessionId::new("session-1")); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("g generate")); + assert!(rendered.contains("r rename")); + } + + #[test] + fn config_footer_renders_status_message_when_present() { + fn buffer_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut app = App::test_default(); + app.active_view = crate::app::ActiveView::Config; + app.config.status_message = Some("Renaming session...".to_owned()); + + terminal + .draw(|frame| { + super::render(frame, &mut app); + }) + .expect("draw"); + + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Renaming session...")); + } +} diff --git a/claude-code-rust/src/ui/config/input.rs b/claude-code-rust/src/ui/config/input.rs new file mode 100644 index 0000000..0763023 --- /dev/null +++ b/claude-code-rust/src/ui/config/input.rs @@ -0,0 +1,70 @@ +use crate::ui::theme; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +pub(super) fn text_input_line(draft: &str, cursor: usize, placeholder: &str) -> Line<'static> { + let cursor_style = + Style::default().fg(Color::Black).bg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD); + let text_style = Style::default().fg(Color::White); + let placeholder_style = Style::default().fg(theme::DIM); + + if draft.is_empty() { + return Line::from(vec![ + Span::styled(" ".to_owned(), cursor_style), + Span::styled(placeholder.to_owned(), placeholder_style), + ]); + } + + let cursor = cursor.min(draft.chars().count()); + let chars = draft.chars().collect::>(); + let prefix = chars[..cursor].iter().collect::(); + let mut spans = Vec::new(); + + if !prefix.is_empty() { + spans.push(Span::styled(prefix, text_style)); + } + + if cursor < chars.len() { + spans.push(Span::styled(chars[cursor].to_string(), cursor_style)); + let suffix = chars[cursor + 1..].iter().collect::(); + if !suffix.is_empty() { + spans.push(Span::styled(suffix, text_style)); + } + } else { + spans.push(Span::styled(" ".to_owned(), cursor_style)); + } + + Line::from(spans) +} + +pub(super) fn render_text_input_field( + frame: &mut Frame, + area: Rect, + draft: &str, + cursor: usize, + placeholder: &str, +) { + let content = text_input_line(draft, cursor, placeholder); + let mut spans = Vec::with_capacity(content.spans.len().saturating_add(2)); + spans.push(Span::styled(" ", Style::default().bg(theme::USER_MSG_BG))); + spans.extend(content.spans); + spans.push(Span::styled(" ", Style::default().bg(theme::USER_MSG_BG))); + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::USER_MSG_BG)), + area, + ); +} + +pub(super) fn add_marketplace_example_lines() -> Vec> { + let dim = Style::default().fg(theme::DIM); + vec![ + Line::from(Span::styled("Examples:", dim.add_modifier(Modifier::BOLD))), + Line::from(Span::styled(" - owner/repo (GitHub)", dim)), + Line::from(Span::styled(" - git@github.com:owner/repo.git (SSH)", dim)), + Line::from(Span::styled(" - https://example.com/marketplace.json", dim)), + Line::from(Span::styled(" - ./path/to/marketplace", dim)), + ] +} diff --git a/claude-code-rust/src/ui/config/mcp.rs b/claude-code-rust/src/ui/config/mcp.rs new file mode 100644 index 0000000..9d73165 --- /dev/null +++ b/claude-code-rust/src/ui/config/mcp.rs @@ -0,0 +1,796 @@ +use super::input::render_text_input_field; +use super::overlay::{ + OverlayChrome, OverlayLayoutSpec, overlay_line_style, render_overlay_separator, + render_overlay_shell, +}; +use super::theme; +use crate::agent::types::{ + ElicitationAction, ElicitationMode, McpServerConnectionStatus, McpServerStatus, + McpServerStatusConfig, +}; +use crate::app::App; +use crate::app::config::{available_mcp_actions, is_mcp_action_available}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap}; + +pub(super) fn render(frame: &mut Frame, area: Rect, app: &App) { + let content_area = area.inner(Margin { vertical: 1, horizontal: 2 }); + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let summary = summary_lines(app); + let summary_height = + wrapped_height(Text::from(summary.clone()), content_area.width).min(content_area.height); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(summary_height), Constraint::Min(1)]) + .split(content_area); + frame.render_widget(Paragraph::new(summary).wrap(Wrap { trim: false }), sections[0]); + + if app.session_id.is_none() { + render_message( + frame, + sections[1], + "No active session", + "Open or resume a session to inspect MCP servers from the live SDK session.", + ); + return; + } + + if app.mcp.in_flight && app.mcp.servers.is_empty() { + render_message( + frame, + sections[1], + "Loading MCP status", + "Waiting for the current session to return MCP server state.", + ); + return; + } + + if app.mcp.servers.is_empty() { + let body = app.mcp.last_error.as_deref().unwrap_or( + "The current session did not report any MCP servers. This view only shows live session-backed MCP state.", + ); + render_message(frame, sections[1], "No MCP servers", body); + return; + } + + render_server_list(frame, sections[1], app); +} + +pub(super) fn render_details_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.mcp_details_overlay() else { + return; + }; + + let server = app.mcp.servers.iter().find(|server| server.name == overlay.server_name); + let action_lines = server.map_or_else(Vec::new, |server| mcp_action_lines(server, overlay)); + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 72, + min_height: 12, + width_percent: 78, + height_percent: 82, + preferred_height: 24, + fullscreen_below: Some((80, 18)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: overlay.server_name.as_str(), + subtitle: None, + help: Some("Up/Down select | Enter run | Esc cancel"), + }, + ); + + if action_lines.is_empty() { + let body = server.map_or_else(server_missing_lines, server_detail_lines); + frame.render_widget(Paragraph::new(body).wrap(Wrap { trim: false }), rendered.body_area); + return; + } + + let action_height = wrapped_height(Text::from(action_lines.clone()), rendered.body_area.width); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1), Constraint::Length(action_height)]) + .split(rendered.body_area); + + let body = server.map_or_else(server_missing_lines, server_detail_lines); + frame.render_widget(Paragraph::new(body).wrap(Wrap { trim: false }), sections[0]); + render_overlay_separator(frame, sections[1]); + frame.render_widget(Paragraph::new(action_lines).wrap(Wrap { trim: false }), sections[2]); +} + +pub(super) fn render_callback_url_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.mcp_callback_url_overlay() else { + return; + }; + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 68, + min_height: 12, + width_percent: 72, + height_percent: 42, + preferred_height: 14, + fullscreen_below: Some((80, 18)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Submit callback URL", + subtitle: Some(overlay.server_name.as_str()), + help: Some("Enter submit | Esc back"), + }, + ); + let header_lines = vec![ + section_heading("Callback"), + Line::from(Span::styled( + "Paste the OAuth callback URL returned by the provider.", + Style::default().fg(theme::DIM), + )), + Line::default(), + detail_kv("Server", &overlay.server_name, Color::White), + ]; + let footer_lines = vec![Line::from(Span::styled( + "The URL is sent to the SDK exactly as pasted.", + Style::default().fg(theme::DIM), + ))]; + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(wrapped_height( + Text::from(header_lines.clone()), + rendered.body_area.width, + )), + Constraint::Length(1), + Constraint::Length(wrapped_height( + Text::from(footer_lines.clone()), + rendered.body_area.width, + )), + Constraint::Min(0), + ]) + .split(rendered.body_area); + frame.render_widget(Paragraph::new(header_lines).wrap(Wrap { trim: false }), sections[0]); + render_text_input_field( + frame, + sections[1], + &overlay.draft, + overlay.cursor, + "https://callback.example/...", + ); + frame.render_widget(Paragraph::new(footer_lines).wrap(Wrap { trim: false }), sections[2]); +} + +pub(super) fn render_elicitation_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.mcp_elicitation_overlay() else { + return; + }; + let action_lines = elicitation_action_lines(overlay); + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 72, + min_height: 16, + width_percent: 80, + height_percent: 78, + preferred_height: 22, + fullscreen_below: Some((90, 20)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "MCP authentication", + subtitle: Some(overlay.request.server_name.as_str()), + help: Some("Up/Down select | Enter respond | Esc cancel"), + }, + ); + let action_height = wrapped_height(Text::from(action_lines.clone()), rendered.body_area.width); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1), Constraint::Length(action_height)]) + .split(rendered.body_area); + frame.render_widget( + Paragraph::new(elicitation_body_lines(overlay)).wrap(Wrap { trim: false }), + sections[0], + ); + render_overlay_separator(frame, sections[1]); + frame.render_widget(Paragraph::new(action_lines).wrap(Wrap { trim: false }), sections[2]); +} + +pub(super) fn render_auth_redirect_overlay(frame: &mut Frame, area: Rect, app: &App) { + let Some(overlay) = app.config.mcp_auth_redirect_overlay() else { + return; + }; + let action_lines = auth_redirect_action_lines(overlay); + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 72, + min_height: 16, + width_percent: 80, + height_percent: 78, + preferred_height: 22, + fullscreen_below: Some((90, 20)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "MCP authentication", + subtitle: Some(overlay.redirect.server_name.as_str()), + help: Some("Up/Down select | Enter run | Esc cancel"), + }, + ); + let action_height = wrapped_height(Text::from(action_lines.clone()), rendered.body_area.width); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1), Constraint::Length(action_height)]) + .split(rendered.body_area); + frame.render_widget( + Paragraph::new(auth_redirect_body_lines(overlay)).wrap(Wrap { trim: false }), + sections[0], + ); + render_overlay_separator(frame, sections[1]); + frame.render_widget(Paragraph::new(action_lines).wrap(Wrap { trim: false }), sections[2]); +} + +fn render_server_list(frame: &mut Frame, area: Rect, app: &App) { + let items = app.mcp.servers.iter().enumerate().map(|(index, server)| { + let selected = index == app.config.mcp_selected_server_index; + ListItem::new(server_list_lines(server, selected)).style(server_row_style(selected)) + }); + + let mut state = ListState::default().with_selected(Some(app.config.mcp_selected_server_index)); + frame.render_stateful_widget(List::new(items).highlight_symbol(""), area, &mut state); +} + +fn summary_lines(app: &App) -> Vec> { + let counts = status_counts(app); + let mut stats_spans = vec![ + badge_span(&format!("total {}", app.mcp.servers.len()), Color::Black, Color::White), + Span::styled(" ", Style::default()), + badge_span(&format!("connected {}", counts.connected), Color::Black, theme::RUST_ORANGE), + Span::styled(" ", Style::default()), + badge_span( + &format!("needs auth {}", counts.needs_auth), + Color::Black, + theme::STATUS_WARNING, + ), + Span::styled(" ", Style::default()), + badge_span(&format!("pending {}", counts.pending), Color::Black, Color::Cyan), + Span::styled(" ", Style::default()), + badge_span(&format!("disabled {}", counts.disabled), Color::White, Color::DarkGray), + Span::styled(" ", Style::default()), + badge_span(&format!("failed {}", counts.failed), Color::White, theme::STATUS_ERROR), + ]; + if app.mcp.in_flight { + stats_spans.push(Span::styled(" ", Style::default())); + stats_spans.push(badge_span("refreshing", Color::Black, Color::Cyan)); + } + + let mut lines = vec![Line::default(), Line::from(stats_spans), Line::default()]; + + if let Some(error) = app.mcp.last_error.as_deref() { + lines.push(Line::from(Span::styled( + format!("Last MCP error: {error}"), + Style::default().fg(theme::STATUS_ERROR), + ))); + lines.push(Line::default()); + } + + lines +} + +fn server_list_lines(server: &McpServerStatus, selected: bool) -> Vec> { + let marker = if selected { ">" } else { " " }; + vec![ + Line::from(vec![ + Span::styled(format!("{marker} {}", server.name), list_title_style(selected)), + Span::styled(" ", Style::default()), + badge_span( + status_label(server.status), + status_badge_fg(server.status), + status_color(server.status), + ), + Span::styled(" ", Style::default()), + badge_span(server.scope.as_deref().unwrap_or("session"), Color::White, Color::DarkGray), + Span::styled(" ", Style::default()), + badge_span(transport_label(server.config.as_ref()), Color::Black, Color::White), + ]), + Line::from(Span::styled( + format!(" {}", server_summary_line(server)), + server_secondary_style(server), + )), + Line::default(), + ] +} + +fn server_detail_lines(server: &McpServerStatus) -> Vec> { + let mut lines = vec![ + section_heading("Status"), + detail_kv("Status", status_label(server.status), status_color(server.status)), + detail_kv( + "Enabled", + if matches!(server.status, McpServerConnectionStatus::Disabled) { "No" } else { "Yes" }, + Color::White, + ), + detail_kv("Scope", server.scope.as_deref().unwrap_or("session"), Color::White), + detail_kv("Transport", transport_label(server.config.as_ref()), Color::White), + detail_kv("Tools", &tool_summary(server.tools.len()), Color::White), + ]; + + if let Some(info) = server.server_info.as_ref() { + lines.push(detail_kv("Server name", &info.name, Color::White)); + lines.push(detail_kv("Version", &info.version, Color::White)); + } + + if let Some(config) = server.config.as_ref() { + lines.push(Line::default()); + lines.push(section_heading("Configuration")); + lines.extend(config_lines(config)); + } + + if let Some(error) = server.error.as_deref() { + lines.push(Line::default()); + lines.push(section_heading("Error")); + lines.push(detail_value(error, theme::STATUS_ERROR)); + } + + lines +} + +fn server_missing_lines() -> Vec> { + vec![ + Line::from(Span::styled( + "The selected server is no longer present in the latest MCP snapshot.", + Style::default().fg(theme::DIM), + )), + Line::default(), + Line::from(Span::styled( + "Close this overlay and refresh the MCP list.", + Style::default().fg(theme::DIM), + )), + ] +} + +fn mcp_action_lines( + server: &McpServerStatus, + overlay: &crate::app::config::McpDetailsOverlayState, +) -> Vec> { + let actions = available_mcp_actions(server); + if actions.is_empty() { + return vec![detail_value("No actions available.", theme::DIM)]; + } + + let mut lines = vec![section_heading("Actions"), Line::default()]; + for (index, action) in actions.into_iter().enumerate() { + let selected = index == overlay.selected_index; + let mut spans = vec![Span::styled( + format!("{} {}", if selected { ">" } else { " " }, action.label()), + overlay_line_style(selected, true), + )]; + if !is_mcp_action_available(server, action) { + spans.push(Span::styled(" ", Style::default())); + spans.push(badge_span("not available", Color::Black, theme::STATUS_WARNING)); + } + lines.push(Line::from(spans)); + } + lines +} + +fn elicitation_body_lines( + overlay: &crate::app::config::McpElicitationOverlayState, +) -> Vec> { + let request = &overlay.request; + let mut lines = vec![ + section_heading("Request"), + detail_kv("Mode", elicitation_mode_label(request.mode), Color::White), + Line::from(Span::styled(request.message.clone(), Style::default().fg(Color::White))), + ]; + if let Some(url) = request.url.as_deref() { + lines.push(Line::default()); + lines.push(section_heading("URL")); + lines.push(detail_value(url, Color::White)); + } + if overlay.browser_opened { + lines.push(Line::default()); + lines.push(detail_value( + "Opened your browser automatically. Finish auth there, then accept below.", + theme::DIM, + )); + } + if let Some(error) = overlay.browser_open_error.as_deref() { + lines.push(Line::default()); + lines.push(detail_value(error, theme::STATUS_ERROR)); + } + if matches!(request.mode, ElicitationMode::Form) { + lines.push(Line::default()); + lines.push(section_heading("Form")); + lines.push(detail_value( + "Structured MCP forms are not editable yet in claude-rs.", + theme::DIM, + )); + if let Some(schema) = request.requested_schema.as_ref() { + let schema_text = + serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string()); + for line in schema_text.lines() { + lines.push(detail_value(line, theme::DIM)); + } + } + } + lines +} + +fn elicitation_action_lines( + overlay: &crate::app::config::McpElicitationOverlayState, +) -> Vec> { + let actions = elicitation_actions(&overlay.request); + let mut lines = vec![section_heading("Actions"), Line::default()]; + let last_index = actions.len().saturating_sub(1); + for (index, action) in actions.iter().enumerate() { + let selected = index == overlay.selected_index; + lines.push(Line::from(Span::styled( + format!("{} {}", if selected { ">" } else { " " }, elicitation_action_label(*action)), + overlay_line_style(selected, true), + ))); + if index < last_index { + lines.push(Line::default()); + } + } + if !lines.is_empty() { + lines.push(Line::default()); + } + lines +} + +fn auth_redirect_body_lines( + overlay: &crate::app::config::McpAuthRedirectOverlayState, +) -> Vec> { + let redirect = &overlay.redirect; + let mut lines = vec![ + section_heading("Request"), + detail_value( + "Claude Code returned a browser auth redirect for this MCP server.", + Color::White, + ), + Line::default(), + section_heading("URL"), + detail_value(&redirect.auth_url, Color::White), + ]; + if overlay.browser_opened { + lines.push(Line::default()); + lines.push(detail_value( + "Opened your browser automatically. Finish auth there, then refresh.", + theme::DIM, + )); + } + if let Some(error) = overlay.browser_open_error.as_deref() { + lines.push(Line::default()); + lines.push(detail_value(error, theme::STATUS_ERROR)); + } + lines +} + +fn auth_redirect_action_lines( + overlay: &crate::app::config::McpAuthRedirectOverlayState, +) -> Vec> { + const ACTIONS: [&str; 3] = ["Refresh", "Copy URL", "Close"]; + let mut lines = vec![section_heading("Actions"), Line::default()]; + for (index, label) in ACTIONS.iter().enumerate() { + let selected = index == overlay.selected_index; + lines.push(Line::from(Span::styled( + format!("{} {}", if selected { ">" } else { " " }, label), + overlay_line_style(selected, true), + ))); + if index + 1 < ACTIONS.len() { + lines.push(Line::default()); + } + } + lines.push(Line::default()); + lines +} + +fn config_lines(config: &McpServerStatusConfig) -> Vec> { + match config { + McpServerStatusConfig::Stdio { command, args, env } => { + let args_label = if args.is_empty() { "(none)".to_owned() } else { args.join(" ") }; + vec![ + detail_kv("Command", command, Color::White), + detail_kv("Args", &args_label, Color::White), + detail_kv("Env", &format!("{} variable(s)", env.len()), Color::White), + ] + } + McpServerStatusConfig::Sse { url, headers } + | McpServerStatusConfig::Http { url, headers } => vec![ + detail_kv("URL", url, Color::White), + detail_kv("Headers", &format!("{} configured", headers.len()), Color::White), + ], + McpServerStatusConfig::Sdk { name } => vec![detail_kv("SDK server", name, Color::White)], + McpServerStatusConfig::ClaudeaiProxy { url, id } => { + vec![detail_kv("Proxy URL", url, Color::White), detail_kv("Proxy ID", id, Color::White)] + } + } +} + +fn render_message(frame: &mut Frame, area: Rect, title: &str, body: &str) { + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + title, + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + Line::default(), + Line::from(Span::styled(body, Style::default().fg(theme::DIM))), + ]) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn detail_kv(key: &str, value: &str, value_color: Color) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{key}: "), Style::default().fg(theme::DIM)), + Span::styled(value.to_owned(), Style::default().fg(value_color)), + ]) +} + +fn detail_value(value: &str, color: Color) -> Line<'static> { + Line::from(Span::styled(value.to_owned(), Style::default().fg(color))) +} + +fn section_heading(title: &str) -> Line<'static> { + Line::from(Span::styled( + title.to_owned(), + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + )) +} + +fn badge_span(label: &str, fg: Color, bg: Color) -> Span<'static> { + Span::styled(format!(" {label} "), Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)) +} + +fn wrapped_height(text: Text<'static>, width: u16) -> u16 { + u16::try_from(Paragraph::new(text).wrap(Wrap { trim: false }).line_count(width)) + .unwrap_or(u16::MAX) + .max(1) +} + +fn list_title_style(selected: bool) -> Style { + let base = Style::default().fg(Color::White); + if selected { + base.fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else { + base.add_modifier(Modifier::BOLD) + } +} + +fn server_row_style(selected: bool) -> Style { + if selected { Style::default().bg(theme::USER_MSG_BG) } else { Style::default() } +} + +fn server_secondary_style(server: &McpServerStatus) -> Style { + if server.error.as_deref().is_some_and(|error| !error.trim().is_empty()) { + Style::default().fg(theme::STATUS_ERROR) + } else { + Style::default().fg(theme::DIM) + } +} + +fn server_summary_line(server: &McpServerStatus) -> String { + if let Some(error) = server.error.as_deref() + && !error.trim().is_empty() + { + return error.to_owned(); + } + + let mut parts = Vec::new(); + if let Some(info) = server.server_info.as_ref() { + parts.push(format!("{} {}", info.name, info.version)); + } + parts.push(tool_summary(server.tools.len())); + match server.config.as_ref() { + Some(McpServerStatusConfig::Stdio { command, .. }) => parts.push(format!("cmd {command}")), + Some( + McpServerStatusConfig::Sse { url, .. } + | McpServerStatusConfig::Http { url, .. } + | McpServerStatusConfig::ClaudeaiProxy { url, .. }, + ) => parts.push(url.clone()), + Some(McpServerStatusConfig::Sdk { name }) => parts.push(format!("sdk {name}")), + None => {} + } + parts.join(" | ") +} + +fn tool_summary(tool_count: usize) -> String { + match tool_count { + 0 => "no tools".to_owned(), + 1 => "1 tool".to_owned(), + count => format!("{count} tools"), + } +} + +fn status_color(status: McpServerConnectionStatus) -> Color { + match status { + McpServerConnectionStatus::Connected => theme::RUST_ORANGE, + McpServerConnectionStatus::NeedsAuth => theme::STATUS_WARNING, + McpServerConnectionStatus::Pending => Color::Cyan, + McpServerConnectionStatus::Disabled => Color::DarkGray, + McpServerConnectionStatus::Failed => theme::STATUS_ERROR, + } +} + +fn status_badge_fg(status: McpServerConnectionStatus) -> Color { + match status { + McpServerConnectionStatus::Connected + | McpServerConnectionStatus::NeedsAuth + | McpServerConnectionStatus::Pending => Color::Black, + McpServerConnectionStatus::Disabled | McpServerConnectionStatus::Failed => Color::White, + } +} + +fn status_label(status: McpServerConnectionStatus) -> &'static str { + match status { + McpServerConnectionStatus::Connected => "connected", + McpServerConnectionStatus::Failed => "failed", + McpServerConnectionStatus::NeedsAuth => "needs auth", + McpServerConnectionStatus::Pending => "pending", + McpServerConnectionStatus::Disabled => "disabled", + } +} + +fn transport_label(config: Option<&McpServerStatusConfig>) -> &'static str { + match config { + Some(McpServerStatusConfig::Stdio { .. }) => "stdio", + Some(McpServerStatusConfig::Sse { .. }) => "sse", + Some(McpServerStatusConfig::Http { .. }) => "http", + Some(McpServerStatusConfig::Sdk { .. }) => "sdk", + Some(McpServerStatusConfig::ClaudeaiProxy { .. }) => "claudeai-proxy", + None => "unknown", + } +} + +fn status_counts(app: &App) -> StatusCounts { + app.mcp.servers.iter().fold(StatusCounts::default(), |mut counts, server| { + match server.status { + McpServerConnectionStatus::Connected => counts.connected += 1, + McpServerConnectionStatus::NeedsAuth => counts.needs_auth += 1, + McpServerConnectionStatus::Pending => counts.pending += 1, + McpServerConnectionStatus::Disabled => counts.disabled += 1, + McpServerConnectionStatus::Failed => counts.failed += 1, + } + counts + }) +} + +fn elicitation_actions( + request: &crate::agent::types::ElicitationRequest, +) -> Vec { + match request.mode { + ElicitationMode::Url => { + vec![ElicitationAction::Accept, ElicitationAction::Decline, ElicitationAction::Cancel] + } + ElicitationMode::Form => vec![ElicitationAction::Decline, ElicitationAction::Cancel], + } +} + +fn elicitation_mode_label(mode: ElicitationMode) -> &'static str { + match mode { + ElicitationMode::Form => "form", + ElicitationMode::Url => "url", + } +} + +fn elicitation_action_label(action: ElicitationAction) -> &'static str { + match action { + ElicitationAction::Accept => "Accept", + ElicitationAction::Decline => "Decline", + ElicitationAction::Cancel => "Cancel", + } +} + +#[derive(Default)] +struct StatusCounts { + connected: usize, + needs_auth: usize, + pending: usize, + disabled: usize, + failed: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::collections::BTreeMap; + + fn render_mcp(app: &App, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| { + super::render(frame, frame.area(), app); + }) + .expect("draw"); + let buffer = terminal.backend().buffer().clone(); + buffer + .content + .chunks(usize::from(buffer.area.width)) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>() + .join("\n") + } + + #[test] + fn renders_no_session_state_without_session_id_header() { + let app = App::test_default(); + let rendered = render_mcp(&app, 120, 28); + assert!(rendered.contains("MCP servers")); + assert!(rendered.contains("No active session")); + assert!(!rendered.contains("Session:")); + } + + #[test] + fn renders_live_server_snapshot_as_list_only() { + let mut app = App::test_default(); + app.session_id = Some(crate::agent::model::SessionId::new("session-1")); + app.mcp.servers = vec![ + McpServerStatus { + name: "notion".to_owned(), + status: McpServerConnectionStatus::NeedsAuth, + server_info: None, + error: None, + config: Some(McpServerStatusConfig::Http { + url: "https://mcp.notion.com/mcp".to_owned(), + headers: BTreeMap::new(), + }), + scope: Some("user".to_owned()), + tools: vec![], + }, + McpServerStatus { + name: "filesystem".to_owned(), + status: McpServerConnectionStatus::Connected, + server_info: Some(crate::agent::types::McpServerInfo { + name: "Filesystem".to_owned(), + version: "1.2.3".to_owned(), + }), + error: None, + config: Some(McpServerStatusConfig::Stdio { + command: "npx".to_owned(), + args: vec![ + "-y".to_owned(), + "@modelcontextprotocol/server-filesystem".to_owned(), + ], + env: BTreeMap::new(), + }), + scope: Some("project".to_owned()), + tools: vec![crate::agent::types::McpTool { + name: "read_file".to_owned(), + description: Some("Read a file".to_owned()), + annotations: Some(crate::agent::types::McpToolAnnotations { + read_only: Some(true), + destructive: Some(false), + open_world: Some(false), + }), + }], + }, + ]; + app.config.mcp_selected_server_index = 1; + + let rendered = render_mcp(&app, 120, 30); + assert!(rendered.contains("total 2")); + assert!(rendered.contains("connected 1")); + assert!(rendered.contains("needs auth 1")); + assert!(rendered.contains("filesystem")); + assert!(rendered.contains("project")); + assert!(rendered.contains("Filesystem 1.2.3")); + assert!(rendered.contains("1 tool")); + assert!(!rendered.contains("Details")); + assert!(!rendered.contains("Servers")); + } +} diff --git a/claude-code-rust/src/ui/config/overlay.rs b/claude-code-rust/src/ui/config/overlay.rs new file mode 100644 index 0000000..83cb0b4 --- /dev/null +++ b/claude-code-rust/src/ui/config/overlay.rs @@ -0,0 +1,143 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + +use crate::ui::theme; + +#[derive(Debug, Clone, Copy)] +pub(super) struct OverlayLayoutSpec { + pub min_width: u16, + pub min_height: u16, + pub width_percent: u16, + pub height_percent: u16, + pub preferred_height: u16, + pub fullscreen_below: Option<(u16, u16)>, + pub inner_margin: Margin, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct OverlayChrome<'a> { + pub title: &'a str, + pub subtitle: Option<&'a str>, + pub help: Option<&'a str>, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct RenderedOverlay { + pub body_area: Rect, +} + +pub(super) fn render_overlay_shell( + frame: &mut Frame, + area: Rect, + layout_spec: OverlayLayoutSpec, + chrome: OverlayChrome<'_>, +) -> RenderedOverlay { + let overlay_area = overlay_rect(area, layout_spec); + frame.render_widget(Clear, overlay_area); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title(chrome.title) + .border_style(Style::default().fg(theme::RUST_ORANGE)), + overlay_area, + ); + + let inner = overlay_area.inner(layout_spec.inner_margin); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(u16::from(chrome.subtitle.is_some())), + Constraint::Min(1), + Constraint::Length(u16::from(chrome.help.is_some())), + ]) + .split(inner); + + if let Some(subtitle) = chrome.subtitle { + frame.render_widget( + Paragraph::new(Line::from(Span::styled(subtitle, Style::default().fg(theme::DIM)))), + sections[0], + ); + } + if let Some(help) = chrome.help { + frame.render_widget( + Paragraph::new(Line::from(Span::styled(help, Style::default().fg(theme::RUST_ORANGE)))), + sections[2], + ); + } + + RenderedOverlay { body_area: sections[1] } +} + +pub(super) fn overlay_line_style(selected: bool, focused: bool) -> Style { + if selected && focused { + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else if selected { + Style::default().fg(ratatui::style::Color::White).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(ratatui::style::Color::White) + } +} + +pub(super) fn render_overlay_header(frame: &mut Frame, area: Rect, title: &str, focused: bool) { + let prefix = if focused { "> " } else { " " }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!("{prefix}{title}"), + if focused { + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::DIM) + }, + ))), + area, + ); +} + +pub(super) fn render_overlay_separator(frame: &mut Frame, area: Rect) { + let width = usize::from(area.width.max(1)); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "-".repeat(width), + Style::default().fg(theme::DIM), + ))), + area, + ); +} + +fn overlay_rect(area: Rect, spec: OverlayLayoutSpec) -> Rect { + if spec + .fullscreen_below + .is_some_and(|(min_width, min_height)| area.width < min_width || area.height < min_height) + { + return area; + } + + let overlay_width = ((u32::from(area.width) * u32::from(spec.width_percent)) / 100) + .try_into() + .unwrap_or(area.width) + .max(spec.min_width) + .clamp(1, area.width); + let percent_height = ((u32::from(area.height) * u32::from(spec.height_percent)) / 100) + .try_into() + .unwrap_or(area.height) + .clamp(1, area.height); + let overlay_height = percent_height + .min(spec.preferred_height.min(area.height)) + .max(spec.min_height.min(area.height)); + + centered_rect_with_size(area, overlay_width, overlay_height) +} + +fn centered_rect_with_size(area: Rect, width: u16, height: u16) -> Rect { + let width = width.min(area.width); + let height = height.min(area.height); + Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width, + height, + } +} diff --git a/claude-code-rust/src/ui/config/plugins.rs b/claude-code-rust/src/ui/config/plugins.rs new file mode 100644 index 0000000..09019cd --- /dev/null +++ b/claude-code-rust/src/ui/config/plugins.rs @@ -0,0 +1,492 @@ +use super::theme; +use crate::app::App; +use crate::app::plugins::{ + PluginCapability, PluginsViewTab, display_label, filtered_installed, + filtered_marketplace_plugins, ordered_installed, relevant_installed_count, search_enabled, + visible_marketplaces, +}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use unicode_width::UnicodeWidthStr; + +pub(super) fn render(frame: &mut Frame, area: Rect, app: &App) { + let body = area.inner(Margin { vertical: 1, horizontal: 1 }); + let top_height = top_region_height(app, body.width); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(top_height), + Constraint::Min(1), + ]) + .split(body); + + frame.render_widget(Paragraph::new(tab_header_line(app)), sections[0]); + render_top_region(frame, sections[2], app); + render_list_region(frame, sections[3], app); +} + +fn render_top_region(frame: &mut Frame, area: Rect, app: &App) { + if search_enabled(app.plugins.active_tab) { + frame.render_widget( + Paragraph::new(search_field_line(app)) + .block( + Block::default() + .borders(Borders::ALL) + .title(if app.plugins.search_focused { + " Search " + } else { + " Search (Up to focus) " + }) + .border_style(if app.plugins.search_focused { + Style::default().fg(theme::RUST_ORANGE) + } else { + Style::default().fg(theme::DIM) + }), + ) + .wrap(Wrap { trim: false }), + area, + ); + return; + } + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled( + "Configured marketplaces", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ), + ])), + area, + ); +} + +fn render_list_region(frame: &mut Frame, area: Rect, app: &App) { + let list_area = + if area.width > 1 { area.inner(Margin { vertical: 0, horizontal: 1 }) } else { area }; + let rendered = match app.plugins.active_tab { + PluginsViewTab::Installed => installed_list(app, list_area.width, list_area.height), + PluginsViewTab::Plugins => plugins_list(app, list_area.width, list_area.height), + PluginsViewTab::Marketplace => marketplace_list(app, list_area.width, list_area.height), + }; + frame.render_widget( + Paragraph::new(rendered.lines).scroll((rendered.scroll, 0)).wrap(Wrap { trim: false }), + list_area, + ); +} + +fn top_region_height(app: &App, width: u16) -> u16 { + if !search_enabled(app.plugins.active_tab) { + return 1; + } + + let content_height = Paragraph::new(search_field_line(app)) + .wrap(Wrap { trim: false }) + .line_count(width.saturating_sub(2).max(1)); + u16::try_from(content_height).unwrap_or(u16::MAX).max(1).saturating_add(2) +} + +fn tab_header_line(app: &App) -> Line<'static> { + let spans = PluginsViewTab::ALL + .into_iter() + .enumerate() + .flat_map(|(index, tab)| { + let active = tab == app.plugins.active_tab; + let count = match tab { + PluginsViewTab::Installed => filtered_installed(&app.plugins).len(), + PluginsViewTab::Plugins => filtered_marketplace_plugins(&app.plugins).len(), + PluginsViewTab::Marketplace => visible_marketplaces(&app.plugins).len(), + }; + let label = format!(" {} ({count}) ", tab.title()); + let mut spans = vec![Span::styled( + label, + if active { + Style::default() + .fg(Color::Black) + .bg(theme::RUST_ORANGE) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + }, + )]; + if index + 1 < PluginsViewTab::ALL.len() { + spans.push(Span::styled(" ", Style::default().fg(theme::DIM))); + } + spans + }) + .collect::>(); + Line::from(spans) +} + +fn search_field_line(app: &App) -> Line<'static> { + let cursor_style = + Style::default().fg(Color::Black).bg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD); + let text_style = Style::default().fg(Color::White); + let hint_style = Style::default().fg(theme::DIM); + let query = app.plugins.search_query_for(app.plugins.active_tab); + + if query.is_empty() { + if app.plugins.search_focused { + return Line::from(vec![ + Span::styled(" ".to_owned(), cursor_style), + Span::styled("Type to filter this list".to_owned(), hint_style), + ]); + } + return Line::from(Span::styled("Type to filter this list", hint_style)); + } + + if app.plugins.search_focused { + return Line::from(vec![ + Span::styled(query.to_owned(), text_style), + Span::styled(" ".to_owned(), cursor_style), + ]); + } + + Line::from(Span::styled(query.to_owned(), text_style)) +} + +fn installed_list(app: &App, viewport_width: u16, viewport_height: u16) -> RenderedList { + let entries = ordered_installed(&app.plugins, &app.cwd_raw); + if entries.is_empty() { + return RenderedList::single( + if app.plugins.loading { + "Loading installed plugins..." + } else if app.plugins.search_query_for(PluginsViewTab::Installed).is_empty() { + "No installed plugins found." + } else { + "No installed plugins match the current search." + }, + viewport_height, + ); + } + + let blocks = entries + .iter() + .enumerate() + .map(|(index, entry)| { + let selected = + index == app.plugins.installed_selected_index && !app.plugins.search_focused; + let mut lines = vec![ + title_line_with_badge(&display_label(&entry.id), Some(entry.capability), selected), + meta_line( + &format!( + "{} | {}{}", + if entry.enabled { "enabled" } else { "disabled" }, + entry.scope, + entry + .version + .as_deref() + .map_or_else(String::new, |version| format!(" | {version}")) + ), + selected, + ), + ]; + if let Some(project_path) = entry.project_path.as_deref() { + lines.push(meta_line(&format!("project | {project_path}"), selected)); + } + lines + }) + .collect::>(); + let relevant_count = relevant_installed_count(&app.plugins, &app.cwd_raw); + let divider_after = if relevant_count > 0 && relevant_count < blocks.len() { + Some(relevant_count.saturating_sub(1)) + } else { + None + }; + let top_label = divider_after.map(|_| section_label_line("Available here")); + let divider = divider_line(viewport_width, "Installed elsewhere"); + + RenderedList::from_blocks_with_sections( + &blocks, + app.plugins.installed_selected_index, + viewport_width, + viewport_height, + top_label, + divider_after, + ÷r, + ) +} + +fn plugins_list(app: &App, viewport_width: u16, viewport_height: u16) -> RenderedList { + let entries = filtered_marketplace_plugins(&app.plugins); + if entries.is_empty() { + return RenderedList::single( + if app.plugins.loading { + "Loading marketplace plugins..." + } else if app.plugins.search_query_for(PluginsViewTab::Plugins).is_empty() { + "No plugins are available from the configured marketplaces." + } else { + "No marketplace plugins match the current search." + }, + viewport_height, + ); + } + + let blocks = entries + .iter() + .enumerate() + .map(|(index, entry)| { + let selected = + index == app.plugins.plugins_selected_index && !app.plugins.search_focused; + let mut lines = vec![title_line(&display_label(&entry.name), selected)]; + lines.push(meta_line(&format!("Plugin: {}", entry.plugin_id), selected)); + if let Some(description) = entry.description.as_deref() { + lines.push(meta_line(description, selected)); + } + if let Some(marketplace_name) = entry.marketplace_name.as_deref() { + lines.push(meta_line(&format!("Marketplace: {marketplace_name}"), selected)); + } + if let Some(version) = entry.version.as_deref() { + lines.push(meta_line(&format!("Version: {version}"), selected)); + } + lines + }) + .collect::>(); + RenderedList::from_blocks( + &blocks, + app.plugins.plugins_selected_index, + viewport_width, + viewport_height, + ) +} + +fn marketplace_list(app: &App, viewport_width: u16, viewport_height: u16) -> RenderedList { + let entries = visible_marketplaces(&app.plugins); + if entries.is_empty() && app.plugins.loading { + return RenderedList::single("Loading configured marketplaces...", viewport_height); + } + let mut blocks = entries + .iter() + .enumerate() + .map(|(index, marketplace)| { + let selected = index == app.plugins.marketplace_selected_index; + let mut lines = vec![title_line(&display_label(&marketplace.name), selected)]; + if let Some(source) = marketplace.source.as_deref() { + lines.push(meta_line(&format!("Source: {source}"), selected)); + } + if let Some(repo) = marketplace.repo.as_deref() { + lines.push(meta_line(&format!("Repo: {repo}"), selected)); + } + lines + }) + .collect::>(); + + blocks.push(vec![ + title_line("Add marketplace", app.plugins.marketplace_selected_index == entries.len()), + meta_line( + "Add a marketplace from a GitHub repo, URL, or local path.", + app.plugins.marketplace_selected_index == entries.len(), + ), + ]); + + RenderedList::from_blocks( + &blocks, + app.plugins.marketplace_selected_index, + viewport_width, + viewport_height, + ) +} + +fn title_line(text: &str, selected: bool) -> Line<'static> { + title_line_with_badge(text, None, selected) +} + +fn title_line_with_badge( + text: &str, + capability: Option, + selected: bool, +) -> Line<'static> { + let mut spans = vec![Span::styled( + text.to_owned(), + if selected { + Style::default().fg(Color::Black).bg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + }, + )]; + if let Some(capability) = capability { + spans.push(Span::styled(" ", Style::default().fg(theme::DIM))); + let (fg, bg) = capability_badge_colors(capability); + spans.push(Span::styled( + format!(" {} ", capability.label()), + Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD), + )); + } + Line::from(spans) +} + +fn meta_line(text: &str, selected: bool) -> Line<'static> { + Line::from(Span::styled( + format!(" {text}"), + if selected { Style::default().fg(Color::White) } else { Style::default().fg(theme::DIM) }, + )) +} + +struct RenderedList { + lines: Vec>, + scroll: u16, +} + +impl RenderedList { + fn single(message: &str, _viewport_height: u16) -> Self { + Self { + lines: vec![Line::from(Span::styled( + message.to_owned(), + Style::default().fg(theme::DIM), + ))], + scroll: 0, + } + } + + fn from_blocks( + blocks: &[Vec>], + selected_index: usize, + viewport_width: u16, + viewport_height: u16, + ) -> Self { + let mut lines = Vec::new(); + let mut selected_start = 0usize; + let mut selected_height = 1usize; + let mut offset = 0usize; + + for (index, block) in blocks.iter().enumerate() { + let block_height = visual_block_height(block, viewport_width).saturating_add(1); + if index == selected_index { + selected_start = offset; + selected_height = block_height; + } + lines.extend(block.iter().cloned()); + lines.push(Line::default()); + offset = offset.saturating_add(block_height); + } + + Self { lines, scroll: selected_scroll(selected_start, selected_height, viewport_height) } + } + + fn from_blocks_with_sections( + blocks: &[Vec>], + selected_index: usize, + viewport_width: u16, + viewport_height: u16, + top_label: Option>, + divider_after: Option, + divider: &Line<'static>, + ) -> Self { + let mut lines = Vec::new(); + let mut selected_start = 0usize; + let mut selected_height = 1usize; + let mut offset = 0usize; + let divider_height = visual_line_height(divider, viewport_width).saturating_add(1); + let top_label_height = top_label + .as_ref() + .map_or(0, |line| visual_line_height(line, viewport_width).saturating_add(1)); + + if let Some(label) = top_label { + lines.push(label); + lines.push(Line::default()); + offset = offset.saturating_add(top_label_height); + } + + for (index, block) in blocks.iter().enumerate() { + let block_height = visual_block_height(block, viewport_width).saturating_add(1); + if index == selected_index { + selected_start = offset; + selected_height = block_height; + } + lines.extend(block.iter().cloned()); + lines.push(Line::default()); + offset = offset.saturating_add(block_height); + + if divider_after == Some(index) { + lines.push(divider.clone()); + lines.push(Line::default()); + offset = offset.saturating_add(divider_height); + } + } + + Self { lines, scroll: selected_scroll(selected_start, selected_height, viewport_height) } + } +} + +fn selected_scroll(selected_start: usize, selected_height: usize, viewport_height: u16) -> u16 { + let viewport_height = usize::from(viewport_height.max(1)); + if selected_start.saturating_add(selected_height) <= viewport_height { + 0 + } else { + u16::try_from( + selected_start.saturating_add(selected_height).saturating_sub(viewport_height), + ) + .unwrap_or(u16::MAX) + } +} + +fn visual_block_height(block: &[Line<'static>], viewport_width: u16) -> usize { + block.iter().map(|line| visual_line_height(line, viewport_width)).sum::() +} + +fn visual_line_height(line: &Line<'static>, viewport_width: u16) -> usize { + let width = usize::from(viewport_width.max(1)); + let content = line.spans.iter().map(|span| span.content.as_ref()).collect::(); + let visual_width = UnicodeWidthStr::width(content.as_str()).max(1); + visual_width.div_ceil(width) +} + +fn section_label_line(text: &str) -> Line<'static> { + Line::from(Span::styled( + text.to_owned(), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )) +} + +fn capability_badge_colors(capability: PluginCapability) -> (Color, Color) { + match capability { + PluginCapability::Skill => (Color::White, Color::Rgb(64, 64, 64)), + PluginCapability::Mcp => (Color::White, Color::Rgb(34, 92, 124)), + } +} + +fn divider_line(viewport_width: u16, label: &str) -> Line<'static> { + let min_width = usize::from(viewport_width.max(20)); + let label_text = format!(" {label} "); + let label_width = UnicodeWidthStr::width(label_text.as_str()); + let fill_width = min_width.saturating_sub(label_width).max(4); + let left_width = fill_width / 2; + let right_width = fill_width.saturating_sub(left_width); + + Line::from(vec![ + Span::styled("─".repeat(left_width), Style::default().fg(theme::DIM)), + Span::styled(label_text, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled("─".repeat(right_width), Style::default().fg(theme::DIM)), + ]) +} + +#[cfg(test)] +mod tests { + use super::{search_field_line, top_region_height}; + use crate::app::App; + use crate::app::plugins::PluginsViewTab; + use ratatui::widgets::{Paragraph, Wrap}; + + #[test] + fn top_region_height_grows_for_wrapped_search_query() { + let mut app = App::test_default(); + app.plugins.active_tab = PluginsViewTab::Installed; + app.plugins.search_focused = true; + app.plugins.installed_search_query = + "search query that should wrap across multiple lines".to_owned(); + + let expected = Paragraph::new(search_field_line(&app)) + .wrap(Wrap { trim: false }) + .line_count(10) + .max(1) + .saturating_add(2); + + assert_eq!(usize::from(top_region_height(&app, 12)), expected); + assert!(top_region_height(&app, 12) > 3); + } +} diff --git a/claude-code-rust/src/ui/config/settings.rs b/claude-code-rust/src/ui/config/settings.rs new file mode 100644 index 0000000..68d333c --- /dev/null +++ b/claude-code-rust/src/ui/config/settings.rs @@ -0,0 +1,324 @@ +use super::theme; +use crate::app::App; +use crate::app::config::{ + resolved_setting, setting_detail_options, setting_display_value, setting_invalid_hint, + setting_specs, +}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use std::fmt::Write as _; + +pub(super) fn render(frame: &mut Frame, area: Rect, app: &mut App) { + if compact_settings_layout(area) { + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(super::MIN_SETTINGS_PANEL_HEIGHT), + Constraint::Length(reserved_hint_height(area)), + ]) + .split(area); + + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title("Settings") + .border_style(Style::default().fg(theme::DIM)), + sections[0], + ); + render_settings_list(frame, panel_body(sections[0]), app, true); + render_settings_limitation_hint(frame, hint_area(area, sections[1])); + return; + } + + let content = padded_body_area(area); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(super::MIN_SETTINGS_PANEL_HEIGHT), + Constraint::Length(reserved_hint_height(content)), + ]) + .split(content); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(44), Constraint::Percentage(56)]) + .spacing(1) + .split(sections[0]); + + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title("Settings") + .border_style(Style::default().fg(theme::DIM)), + columns[0], + ); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title("Details") + .border_style(Style::default().fg(theme::DIM)), + columns[1], + ); + + render_settings_list(frame, panel_body(columns[0]), app, false); + frame.render_widget( + Paragraph::new(setting_detail_lines(app)).wrap(Wrap { trim: false }), + panel_body(columns[1]), + ); + render_settings_limitation_hint(frame, hint_area(content, sections[1])); +} + +pub(super) fn compact_settings_layout(area: Rect) -> bool { + area.width < COMPACT_SETTINGS_MIN_WIDTH || area.height < COMPACT_SETTINGS_MIN_HEIGHT +} + +pub(super) fn settings_hint_height(viewport_width: u16) -> u16 { + if viewport_width == 0 { + return 0; + } + + let line_count = Paragraph::new(super::SETTINGS_LIMITATION_HINT) + .wrap(Wrap { trim: false }) + .line_count(viewport_width); + u16::try_from(line_count).unwrap_or(u16::MAX) +} + +pub(super) fn setting_detail_lines(app: &App) -> Vec> { + let Some(spec) = app.config.selected_setting_spec() else { + return vec![detail_text("No setting selected.")]; + }; + let resolved = resolved_setting(app, spec); + + let mut lines = vec![detail_title(spec.label), detail_text(spec.description)]; + + if !spec.supported { + lines.push(Line::default()); + lines.push(unsupported_hint()); + } + + let options = setting_detail_options(app, spec); + if !options.is_empty() { + lines.push(Line::default()); + lines.push(detail_section_title("Options")); + lines.extend(options.into_iter().map(detail_option)); + } + + if let Some(hint) = setting_invalid_hint(spec, resolved.validation) { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + format!( + "Invalid persisted value detected. Runtime uses the fallback until you save a valid selection. {hint}" + ), + Style::default().fg(theme::STATUS_ERROR), + ))); + } + + lines +} + +fn render_settings_list(frame: &mut Frame, area: Rect, app: &mut App, compact: bool) { + let mut state = ListState::default() + .with_selected(Some(app.config.selected_setting_index)) + .with_offset(app.config.settings_scroll_offset); + let list = List::new(setting_items(app, compact, area.width)); + frame.render_stateful_widget(list, area, &mut state); + app.config.settings_scroll_offset = state.offset(); +} + +fn padded_body_area(area: Rect) -> Rect { + area.inner(Margin { vertical: 1, horizontal: 2 }) +} + +fn panel_body(area: Rect) -> Rect { + area.inner(Margin { vertical: 1, horizontal: 2 }) +} + +fn reserved_hint_height(base_area: Rect) -> u16 { + if base_area.height == 0 { + return 0; + } + + let desired = settings_hint_height(hint_text_width(base_area)).max(1); + let max_hint = base_area.height.saturating_sub(super::MIN_SETTINGS_PANEL_HEIGHT).max(1); + desired.min(max_hint) +} + +fn hint_text_width(base_area: Rect) -> u16 { + base_area.width.saturating_sub(4) +} + +fn hint_area(base_area: Rect, hint_row: Rect) -> Rect { + Rect { + x: base_area.x.saturating_add(2), + y: hint_row.y, + width: hint_text_width(base_area), + height: hint_row.height, + } +} + +fn render_settings_limitation_hint(frame: &mut Frame, area: Rect) { + frame.render_widget( + Paragraph::new(super::SETTINGS_LIMITATION_HINT) + .style(Style::default().fg(Color::White)) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn setting_items(app: &App, compact: bool, viewport_width: u16) -> Vec> { + let mut items = Vec::new(); + for (index, spec) in setting_specs().iter().enumerate() { + let resolved = resolved_setting(app, spec); + let mut lines = vec![config_line( + app.config.selected_setting_index == index, + spec.label, + &setting_display_value(app, spec, &resolved), + resolved.validation.is_invalid(), + )]; + if let Some(hint) = setting_invalid_hint(spec, resolved.validation) { + lines.extend(wrap_styled_text( + &format!(" {hint}"), + Style::default().fg(theme::STATUS_ERROR), + viewport_width, + )); + } + if compact && !spec.supported { + lines.extend(unsupported_hint_lines(viewport_width)); + } + if index + 1 < setting_specs().len() { + lines.push(Line::default()); + } + items.push(ListItem::new(lines)); + } + items +} + +fn detail_title(text: &str) -> Line<'static> { + Line::from(Span::styled( + text.to_owned(), + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + )) +} + +fn detail_text(text: &str) -> Line<'static> { + Line::from(Span::styled(text.to_owned(), Style::default().fg(Color::White))) +} + +fn detail_section_title(text: &str) -> Line<'static> { + Line::from(Span::styled( + text.to_owned(), + Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD), + )) +} + +fn detail_option(text: String) -> Line<'static> { + Line::from(vec![ + Span::styled("- ", Style::default().fg(theme::DIM)), + Span::styled(text, Style::default().fg(Color::White)), + ]) +} + +fn unsupported_hint() -> Line<'static> { + Line::from(Span::styled( + " Warning: not supported yet; this setting will not affect sessions.".to_owned(), + Style::default().fg(Color::Yellow), + )) +} + +fn unsupported_hint_lines(viewport_width: u16) -> Vec> { + wrap_styled_text( + " Warning: not supported yet; this setting will not affect sessions.", + Style::default().fg(Color::Yellow), + viewport_width, + ) +} + +fn wrap_styled_text(text: &str, style: Style, viewport_width: u16) -> Vec> { + let width = usize::from(viewport_width.max(1)); + if width == 0 { + return Vec::new(); + } + + let indent_len = text.chars().take_while(|ch| ch.is_whitespace()).count(); + let indent = text.chars().take(indent_len).collect::(); + let content = text.chars().skip(indent_len).collect::(); + let indent_width = Line::raw(indent.as_str()).width(); + let available_width = width.saturating_sub(indent_width).max(1); + + let mut wrapped = Vec::new(); + let mut current = String::new(); + + for word in content.split_whitespace() { + push_wrapped_word(&mut wrapped, &mut current, word, available_width); + } + + if !current.is_empty() { + wrapped.push(format!("{indent}{current}")); + } + + if wrapped.is_empty() { + wrapped.push(indent); + } + + wrapped.into_iter().map(|line| Line::from(Span::styled(line, style))).collect() +} + +fn push_wrapped_word( + wrapped: &mut Vec, + current: &mut String, + word: &str, + available_width: usize, +) { + let mut remaining = word; + while !remaining.is_empty() { + let candidate = if current.is_empty() { + remaining.to_owned() + } else { + format!("{current} {remaining}") + }; + + if Line::raw(candidate.as_str()).width() <= available_width { + current.clear(); + current.push_str(&candidate); + break; + } + + if current.is_empty() { + let split = split_to_width(remaining, available_width); + let head = remaining[..split].to_owned(); + wrapped.push(head); + remaining = &remaining[split..]; + } else { + wrapped.push(std::mem::take(current)); + } + } +} + +fn split_to_width(text: &str, available_width: usize) -> usize { + let mut width = 0; + let mut split = 0; + for (byte_index, ch) in text.char_indices() { + let ch_width = Line::raw(ch.to_string()).width(); + if byte_index > 0 && width + ch_width > available_width { + break; + } + width += ch_width; + split = byte_index + ch.len_utf8(); + } + split.max(1) +} + +fn config_line(selected: bool, label: &str, value: &str, invalid: bool) -> Line<'static> { + let mut line = String::new(); + line.push(if selected { '>' } else { ' ' }); + line.push(' '); + line.push_str(label); + let marker = if invalid { " !" } else { "" }; + let _ = write!(&mut line, ": {value}{marker}"); + Line::from(Span::styled(line, Style::default().fg(Color::White))) +} + +const COMPACT_SETTINGS_MIN_WIDTH: u16 = 90; +const COMPACT_SETTINGS_MIN_HEIGHT: u16 = 20; diff --git a/claude-code-rust/src/ui/config/status.rs b/claude-code-rust/src/ui/config/status.rs new file mode 100644 index 0000000..8f2fb7f --- /dev/null +++ b/claude-code-rust/src/ui/config/status.rs @@ -0,0 +1,299 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::theme; +use crate::app::App; +use ratatui::Frame; +use ratatui::layout::{Margin, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Wrap}; + +pub(super) fn render(frame: &mut Frame, area: Rect, app: &App) { + let lines = status_lines(app); + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: false }), + area.inner(Margin { vertical: 1, horizontal: 2 }), + ); +} + +pub(crate) fn status_lines(app: &App) -> Vec> { + let mut lines = Vec::new(); + + // ---- Session ---- + section_header(&mut lines, "Session"); + kv_line(&mut lines, "Version", env!("CARGO_PKG_VERSION")); + kv_line(&mut lines, "Session name", &derive_session_name(app)); + + let session_id_str = app + .session_id + .as_ref() + .map_or_else(|| "(none)".to_owned(), std::string::ToString::to_string); + kv_line(&mut lines, "Session ID", &session_id_str); + + kv_line(&mut lines, "cwd", &app.cwd); + + if let Some(ref branch) = app.git_branch { + kv_line(&mut lines, "Git branch", branch); + } + + lines.push(Line::default()); + + // ---- Account ---- + if let Some(ref account) = app.account_info { + section_header(&mut lines, "Account"); + kv_line(&mut lines, "Login method", &login_method_label(account)); + if let Some(ref org) = account.organization + && !org.is_empty() + { + kv_line(&mut lines, "Organization", org); + } + if let Some(ref email) = account.email + && !email.is_empty() + { + kv_line(&mut lines, "Email", email); + } + if let Some(ref sub) = account.subscription_type + && !sub.is_empty() + { + kv_line(&mut lines, "Subscription", sub); + } + lines.push(Line::default()); + } + + // ---- Model ---- + section_header(&mut lines, "Model"); + kv_line(&mut lines, "Model", &model_display(app)); + + if let Some(ref mode) = app.mode { + kv_line(&mut lines, "Mode", &mode.current_mode_name); + } + + lines.push(Line::default()); + + // ---- Settings ---- + section_header(&mut lines, "Settings"); + + let memory_path = resolve_memory_path(app); + kv_line(&mut lines, "Memory", &memory_path); + + let sources = setting_sources(app); + kv_line(&mut lines, "Setting sources", &sources); + + lines +} + +fn section_header(lines: &mut Vec>, title: &str) { + lines.push(Line::from(Span::styled( + title.to_owned(), + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + ))); +} + +fn kv_line(lines: &mut Vec>, key: &str, value: &str) { + lines.push(Line::from(vec![ + Span::styled(format!(" {key}: "), Style::default().fg(theme::DIM)), + Span::styled(value.to_owned(), Style::default().fg(Color::White)), + ])); +} + +fn derive_session_name(app: &App) -> String { + if let Some(ref sid) = app.session_id { + let sid_str = sid.to_string(); + if let Some(session) = app.recent_sessions.iter().find(|s| s.session_id == sid_str) { + if let Some(ref title) = session.custom_title + && !title.trim().is_empty() + { + return title.clone(); + } + if !session.summary.trim().is_empty() { + let summary = &session.summary; + return if summary.len() > 60 { + format!("{}...", &summary[..57]) + } else { + summary.clone() + }; + } + if let Some(ref prompt) = session.first_prompt + && !prompt.trim().is_empty() + { + return if prompt.len() > 60 { + format!("{}...", &prompt[..57]) + } else { + prompt.clone() + }; + } + } + } + "(unnamed session)".to_owned() +} + +fn model_display(app: &App) -> String { + if app.model_name.is_empty() { + return "(not set)".to_owned(); + } + if let Some(model) = app.available_models.iter().find(|m| m.id == app.model_name) { + let mut label = model.display_name.clone(); + if let Some(ref desc) = model.description { + label.push_str(" - "); + label.push_str(desc); + } + return label; + } + app.model_name.clone() +} + +pub(crate) fn login_method_label(account: &crate::agent::types::AccountInfo) -> String { + if let Some(ref source) = account.api_key_source { + match source.as_str() { + "oauth" => return "Claude Max Account".to_owned(), + "user" => return "User API key".to_owned(), + "project" => return "Project API key".to_owned(), + "org" => return "Organization API key".to_owned(), + "temporary" => return "Temporary key".to_owned(), + other if !other.is_empty() => return other.to_owned(), + _ => {} + } + } + if let Some(ref source) = account.token_source + && !source.is_empty() + { + return source.clone(); + } + "Unknown".to_owned() +} + +fn resolve_memory_path(app: &App) -> String { + let Some(home) = dirs::home_dir() else { + return "(unable to resolve home directory)".to_owned(); + }; + let encoded = encode_project_path(&app.cwd_raw); + let memory_md = + home.join(".claude").join("projects").join(&encoded).join("memory").join("MEMORY.md"); + + if memory_md.exists() { + format!("auto memory ({})", memory_md.display()) + } else { + "(no memory file found)".to_owned() + } +} + +pub(crate) fn encode_project_path(cwd: &str) -> String { + cwd.replace(['/', '\\'], "-").replace(':', "-").trim_start_matches('-').to_owned() +} + +fn setting_sources(app: &App) -> String { + let mut sources = Vec::new(); + if app.config.settings_path.is_some() { + sources.push("User settings"); + } + if app.config.local_settings_path.is_some() { + sources.push("Project local settings"); + } + if app.config.preferences_path.is_some() { + sources.push("Preferences"); + } + if sources.is_empty() { "(none loaded)".to_owned() } else { sources.join(", ") } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_lines_contains_version() { + let app = App::test_default(); + let text = lines_to_string(&status_lines(&app)); + assert!(text.contains(env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn status_lines_shows_cwd() { + let mut app = App::test_default(); + app.cwd = "/test/project".to_owned(); + let text = lines_to_string(&status_lines(&app)); + assert!(text.contains("/test/project")); + } + + #[test] + fn status_lines_shows_model() { + let app = App::test_default(); + let text = lines_to_string(&status_lines(&app)); + assert!(text.contains(&app.model_name) || text.contains("(not set)")); + } + + #[test] + fn status_lines_unnamed_session_fallback() { + let app = App::test_default(); + let text = lines_to_string(&status_lines(&app)); + assert!(text.contains("(unnamed session)")); + } + + #[test] + fn status_lines_uses_custom_title() { + let mut app = App::test_default(); + app.session_id = Some(crate::agent::model::SessionId::new("test-sess-1")); + app.recent_sessions = vec![crate::app::RecentSessionInfo { + session_id: "test-sess-1".to_owned(), + summary: String::new(), + last_modified_ms: 0, + file_size_bytes: 0, + cwd: None, + git_branch: None, + custom_title: Some("My Custom Title".to_owned()), + first_prompt: None, + }]; + let text = lines_to_string(&status_lines(&app)); + assert!(text.contains("My Custom Title")); + } + + #[test] + fn section_headers_present() { + let app = App::test_default(); + let text = lines_to_string(&status_lines(&app)); + assert!(text.contains("Session")); + assert!(text.contains("Model")); + assert!(text.contains("Settings")); + } + + #[test] + fn encode_project_path_unix() { + assert_eq!(encode_project_path("/home/user/project"), "home-user-project"); + } + + #[test] + fn encode_project_path_windows() { + assert_eq!( + encode_project_path("C:\\Users\\User\\Desktop\\project"), + "C--Users-User-Desktop-project" + ); + } + + #[test] + fn login_method_maps_oauth() { + let account = crate::agent::types::AccountInfo { + api_key_source: Some("oauth".to_owned()), + ..Default::default() + }; + assert_eq!(login_method_label(&account), "Claude Max Account"); + } + + #[test] + fn login_method_maps_user_key() { + let account = crate::agent::types::AccountInfo { + api_key_source: Some("user".to_owned()), + ..Default::default() + }; + assert_eq!(login_method_label(&account), "User API key"); + } + + #[test] + fn login_method_falls_back_to_unknown() { + let account = crate::agent::types::AccountInfo::default(); + assert_eq!(login_method_label(&account), "Unknown"); + } + + fn lines_to_string(lines: &[Line<'_>]) -> String { + lines.iter().map(std::string::ToString::to_string).collect::>().join("\n") + } +} diff --git a/claude-code-rust/src/ui/config/usage.rs b/claude-code-rust/src/ui/config/usage.rs new file mode 100644 index 0000000..ed98d8a --- /dev/null +++ b/claude-code-rust/src/ui/config/usage.rs @@ -0,0 +1,373 @@ +use super::theme; +use crate::app::usage; +use crate::app::{App, ExtraUsage, UsageWindow}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; + +pub(super) fn render(frame: &mut Frame, area: Rect, app: &App) { + let content_area = area.inner(Margin { vertical: 1, horizontal: 2 }); + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let windows = app.usage.snapshot.as_ref().map_or_else(Vec::new, usage::visible_windows); + + let mut constraints = vec![Constraint::Length(1)]; + if windows.is_empty() { + constraints.push(Constraint::Min(3)); + } else { + for window in &windows { + constraints.push(Constraint::Length(window_height(window, content_area.width))); + constraints.push(Constraint::Length(1)); + } + if let Some(extra_usage) = + app.usage.snapshot.as_ref().and_then(|snapshot| snapshot.extra_usage.as_ref()) + { + constraints + .push(Constraint::Length(extra_usage_height(extra_usage, content_area.width))); + constraints.push(Constraint::Length(1)); + } + if let Some(error) = app.usage.last_error.as_deref() { + constraints.push(Constraint::Length(error_height(error, content_area.width))); + } + constraints.push(Constraint::Min(0)); + } + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(content_area); + + render_spacer(frame, sections[0]); + + if windows.is_empty() { + render_empty_state(frame, sections[1], app); + return; + } + + let Some(snapshot) = app.usage.snapshot.as_ref() else { + return; + }; + let mut section_index = 1usize; + for window in &windows { + render_window(frame, sections[section_index], window); + render_spacer(frame, sections[section_index + 1]); + section_index += 2; + } + + if let Some(extra_usage) = snapshot.extra_usage.as_ref() { + render_extra_usage(frame, sections[section_index], extra_usage); + render_spacer(frame, sections[section_index + 1]); + section_index += 2; + } + + if let Some(error) = app.usage.last_error.as_deref() { + render_error(frame, sections[section_index], error); + } +} + +fn render_spacer(frame: &mut Frame, area: Rect) { + frame.render_widget(Paragraph::new(Line::default()), area); +} + +fn render_empty_state(frame: &mut Frame, area: Rect, app: &App) { + if app.usage.in_flight { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Loading usage data...", + Style::default().fg(theme::DIM), + ))), + area, + ); + return; + } + + let (title, body, color) = if let Some(error) = app.usage.last_error.as_deref() { + ("Unable to load usage", error, theme::STATUS_ERROR) + } else { + ( + "No usage snapshot yet", + "Press r to fetch Claude usage for the current account.", + theme::DIM, + ) + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(color)); + frame.render_widget(block.clone(), area); + frame.render_widget( + Paragraph::new(body).wrap(Wrap { trim: false }), + area.inner(Margin { vertical: 1, horizontal: 2 }), + ); +} + +fn render_window(frame: &mut Frame, area: Rect, window: &UsageWindow) { + let label_line = Line::from(vec![ + Span::styled(window.label.to_owned(), Style::default().add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}", window_detail_text(window)), Style::default().fg(theme::DIM)), + ]); + let label_height = wrapped_height(Text::from(vec![label_line.clone()]), area.width); + let reset_line = usage::format_window_reset(window).unwrap_or_default(); + let reset_height = wrapped_height( + Text::from(vec![Line::from(Span::styled( + reset_line.clone(), + Style::default().fg(theme::DIM), + ))]), + area.width, + ); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(label_height), + Constraint::Length(1), + Constraint::Length(reset_height), + ]) + .split(area); + frame.render_widget(Paragraph::new(label_line).wrap(Wrap { trim: false }), sections[0]); + + let gauge_area = sections[1]; + let gauge_style = gauge_style(window.utilization); + frame.render_widget( + Gauge::default() + .gauge_style(gauge_style) + .label("") + .ratio((window.utilization / 100.0).clamp(0.0, 1.0)), + gauge_area, + ); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled(reset_line, Style::default().fg(theme::DIM)))) + .wrap(Wrap { trim: false }), + sections[2], + ); +} + +fn render_extra_usage(frame: &mut Frame, area: Rect, extra_usage: &ExtraUsage) { + let detail = format_extra_usage(extra_usage); + let block = Block::default() + .borders(Borders::ALL) + .title("Extra credits") + .border_style(Style::default().fg(theme::DIM)); + frame.render_widget(block.clone(), area); + let inner = area.inner(Margin { vertical: 1, horizontal: 2 }); + frame.render_widget( + Paragraph::new(Line::from(Span::styled(detail, Style::default().fg(Color::White)))) + .wrap(Wrap { trim: false }), + inner, + ); +} + +fn render_error(frame: &mut Frame, area: Rect, error: &str) { + let block = Block::default() + .borders(Borders::ALL) + .title("Latest refresh error") + .border_style(Style::default().fg(theme::STATUS_ERROR)); + frame.render_widget(block.clone(), area); + frame.render_widget( + Paragraph::new(error).wrap(Wrap { trim: false }), + area.inner(Margin { vertical: 1, horizontal: 2 }), + ); +} + +fn window_detail_text(window: &UsageWindow) -> String { + format!("{:.0}% used", window.utilization) +} + +fn gauge_style(utilization: f64) -> Style { + let color = if utilization >= 85.0 { + theme::STATUS_ERROR + } else if utilization >= 65.0 { + theme::STATUS_WARNING + } else { + theme::RUST_ORANGE + }; + Style::default().fg(color).bg(Color::DarkGray) +} + +fn format_extra_usage(extra_usage: &ExtraUsage) -> String { + let currency = extra_usage.currency.as_deref().unwrap_or("USD"); + match (extra_usage.used_credits, extra_usage.monthly_limit) { + (Some(used), Some(limit)) => format!("{used:.2} of {limit:.2} {currency} used"), + (Some(used), None) => format!("{used:.2} {currency} used"), + (None, Some(limit)) => format!("{limit:.2} {currency} limit"), + (None, None) => match extra_usage.utilization { + Some(utilization) => format!("{utilization:.0}% of monthly budget"), + None => "Usage available".to_owned(), + }, + } +} + +fn window_height(window: &UsageWindow, width: u16) -> u16 { + let label_line = Line::from(vec![ + Span::styled(window.label.to_owned(), Style::default().add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}", window_detail_text(window)), Style::default().fg(theme::DIM)), + ]); + let reset_line = Line::from(Span::styled( + usage::format_window_reset(window).unwrap_or_default(), + Style::default().fg(theme::DIM), + )); + wrapped_height(Text::from(vec![label_line]), width) + .saturating_add(1) + .saturating_add(wrapped_height(Text::from(vec![reset_line]), width)) +} + +fn extra_usage_height(extra_usage: &ExtraUsage, width: u16) -> u16 { + let inner_width = width.saturating_sub(4); + wrapped_height( + Text::from(vec![Line::from(Span::styled( + format_extra_usage(extra_usage), + Style::default().fg(Color::White), + ))]), + inner_width, + ) + .saturating_add(2) +} + +fn error_height(error: &str, width: u16) -> u16 { + let inner_width = width.saturating_sub(4); + wrapped_height(Text::from(error.to_owned()), inner_width).saturating_add(2) +} + +fn wrapped_height(text: Text<'static>, width: u16) -> u16 { + u16::try_from(Paragraph::new(text).wrap(Wrap { trim: false }).line_count(width)) + .unwrap_or(u16::MAX) + .max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{UsageSnapshot, UsageSourceKind, UsageSourceMode, UsageState}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::time::SystemTime; + + fn render_usage_rows(app: &App, width: u16, height: u16) -> Vec { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| { + super::render(frame, frame.area(), app); + }) + .expect("draw"); + let buffer = terminal.backend().buffer().clone(); + buffer + .content + .chunks(usize::from(buffer.area.width)) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect() + } + + fn render_usage(app: &App) -> String { + render_usage_rows(app, 100, 24).join("\n") + } + + fn usage_app() -> App { + let mut app = App::test_default(); + app.usage = UsageState { + snapshot: None, + in_flight: false, + last_error: None, + active_source: UsageSourceMode::Auto, + last_attempted_source: None, + }; + app + } + + #[test] + fn renders_idle_state() { + let app = usage_app(); + let rendered = render_usage(&app); + assert!(rendered.contains("No usage snapshot yet")); + } + + #[test] + fn renders_loading_state() { + let mut app = usage_app(); + app.usage.in_flight = true; + let rendered = render_usage(&app); + assert!(rendered.contains("Loading usage data...")); + } + + #[test] + fn renders_snapshot_with_extra_usage_and_error() { + let mut app = usage_app(); + app.usage.snapshot = Some(UsageSnapshot { + source: UsageSourceKind::Oauth, + fetched_at: SystemTime::now(), + five_hour: Some(UsageWindow { + label: "5-hour", + utilization: 47.0, + resets_at: None, + reset_description: Some("resets in 2h 14m".to_owned()), + }), + seven_day: Some(UsageWindow { + label: "7-day", + utilization: 62.0, + resets_at: None, + reset_description: Some("resets in 4d 11h".to_owned()), + }), + seven_day_opus: None, + seven_day_sonnet: None, + extra_usage: Some(ExtraUsage { + monthly_limit: Some(20.0), + used_credits: Some(12.4), + utilization: Some(62.0), + currency: Some("USD".to_owned()), + }), + }); + app.usage.last_error = Some("Network timeout while refreshing cached data.".to_owned()); + + let rendered = render_usage(&app); + assert!(rendered.contains("5-hour")); + assert_eq!(rendered.matches("47%").count(), 1); + assert!(rendered.contains("12.40")); + assert!(rendered.contains("20.00")); + assert!(rendered.contains("USD")); + assert!(rendered.contains("Extra credits")); + assert!(rendered.contains("Latest refresh error")); + assert!(!rendered.contains("source:")); + + let rendered_lines = rendered.lines().collect::>(); + let first_reset_index = rendered_lines + .iter() + .position(|line| line.contains("resets in 2h 14m")) + .expect("reset line"); + assert!(rendered_lines[first_reset_index + 1].trim().is_empty()); + } + + #[test] + fn extra_usage_wraps_inside_card_on_narrow_widths() { + let mut app = usage_app(); + app.usage.snapshot = Some(UsageSnapshot { + source: UsageSourceKind::Oauth, + fetched_at: SystemTime::now(), + five_hour: Some(UsageWindow { + label: "5-hour", + utilization: 47.0, + resets_at: None, + reset_description: Some("resets soon".to_owned()), + }), + seven_day: None, + seven_day_opus: None, + seven_day_sonnet: None, + extra_usage: Some(ExtraUsage { + monthly_limit: Some(100.0), + used_credits: Some(99.99), + utilization: Some(99.0), + currency: Some("USD".to_owned()), + }), + }); + + let rows = render_usage_rows(&app, 20, 18); + assert!(rows.iter().any(|row| row.contains("Extra credits"))); + assert!(rows.iter().any(|row| row.contains("99.99"))); + assert!(rows.iter().any(|row| row.contains("USD"))); + assert!(rows.iter().any(|row| row.contains("used"))); + } +} diff --git a/claude-code-rust/src/ui/diff.rs b/claude-code-rust/src/ui/diff.rs new file mode 100644 index 0000000..d39b196 --- /dev/null +++ b/claude-code-rust/src/ui/diff.rs @@ -0,0 +1,453 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::agent::model; +use crate::ui::theme; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use similar::TextDiff; + +/// Render a diff with proper unified-style output using the `similar` crate. +/// The model `Diff` struct provides `old_text`/`new_text` -- we compute the actual +/// line-level changes and show only changed lines with context. +pub fn render_diff(diff: &model::Diff) -> Vec> { + let mut lines: Vec> = Vec::new(); + + // File path header + let name = diff.path.file_name().map_or_else( + || diff.path.to_string_lossy().into_owned(), + |f| f.to_string_lossy().into_owned(), + ); + let mut header_spans = + vec![Span::styled(name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD))]; + if let Some(repository) = diff.repository.as_deref() { + header_spans + .push(Span::styled(format!(" [{repository}]"), Style::default().fg(theme::DIM))); + } + lines.push(Line::from(header_spans)); + + let old = diff.old_text.as_deref().unwrap_or(""); + let new = &diff.new_text; + let text_diff = TextDiff::from_lines(old, new); + + // Use unified diff with 3 lines of context -- only shows changed hunks + // instead of the full file content. + let udiff = text_diff.unified_diff(); + for hunk in udiff.iter_hunks() { + // Extract the @@ header from the hunk's Display output (first line). + let hunk_str = hunk.to_string(); + if let Some(header) = hunk_str.lines().next() + && header.starts_with("@@") + { + lines.push(Line::from(Span::styled( + header.to_owned(), + Style::default().fg(Color::Cyan), + ))); + } + + for change in hunk.iter_changes() { + let value = change.as_str().unwrap_or("").trim_end_matches('\n'); + let (prefix, style) = match change.tag() { + similar::ChangeTag::Delete => ("-", Style::default().fg(Color::Red)), + similar::ChangeTag::Insert => ("+", Style::default().fg(Color::Green)), + similar::ChangeTag::Equal => (" ", Style::default().fg(theme::DIM)), + }; + lines.push(Line::from(Span::styled(format!("{prefix} {value}"), style))); + } + } + + lines +} + +pub fn looks_like_unified_diff(text: &str) -> bool { + let mut saw_hunk = false; + let mut saw_file_header = false; + let mut saw_metadata = false; + + for line in text.lines().take(64) { + if line.starts_with("@@") { + saw_hunk = true; + } else if line.starts_with("--- ") || line.starts_with("+++ ") { + saw_file_header = true; + } else if line.starts_with("diff --git ") + || line.starts_with("index ") + || line.starts_with("new file mode ") + || line.starts_with("deleted file mode ") + || line.starts_with("rename from ") + || line.starts_with("rename to ") + { + saw_metadata = true; + } + } + + saw_hunk && (saw_file_header || saw_metadata) +} + +pub fn render_raw_unified_diff(text: &str) -> Vec> { + let mut lines = Vec::new(); + + for line in text.split('\n') { + lines.push(render_raw_diff_line(line)); + } + + if lines.is_empty() { + lines.push(Line::default()); + } + + lines +} + +fn render_raw_diff_line(line: &str) -> Line<'static> { + let style = if line.starts_with("diff --git ") + || line.starts_with("index ") + || line.starts_with("new file mode ") + || line.starts_with("deleted file mode ") + || line.starts_with("similarity index ") + || line.starts_with("rename from ") + || line.starts_with("rename to ") + { + Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + } else if line.starts_with("@@") { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else if line.starts_with("+++ ") { + Style::default().fg(Color::Green) + } else if line.starts_with("--- ") { + Style::default().fg(Color::Red) + } else if line.starts_with('+') { + Style::default().fg(Color::Green) + } else if line.starts_with('-') { + Style::default().fg(Color::Red) + } else if line.starts_with('\\') { + Style::default().fg(theme::DIM).add_modifier(Modifier::ITALIC) + } else { + Style::default().fg(theme::DIM) + }; + + Line::from(Span::styled(line.to_owned(), style)) +} + +/// Check if a tool call title references a markdown file. +#[allow(clippy::case_sensitive_file_extension_comparisons)] +pub fn is_markdown_file(title: &str) -> bool { + let lower = title.to_lowercase(); + lower.ends_with(".md") || lower.ends_with(".mdx") || lower.ends_with(".markdown") +} + +/// Extract a language tag from the file extension in a tool call title. +/// Returns the raw extension (e.g. "rs", "py", "toml") which syntect +/// can resolve to the correct syntax definition. Falls back to empty string. +pub fn lang_from_title(title: &str) -> String { + // Title may be "src/main.rs" or "Read src/main.rs" - find last path-like token + title + .split_whitespace() + .rev() + .find_map(|token| { + let ext = token.rsplit('.').next()?; + // Ignore if the "extension" is the whole token (no dot found) + if ext.len() < token.len() { Some(ext.to_lowercase()) } else { None } + }) + .unwrap_or_default() +} + +/// Strip an outer markdown code fence if the text is entirely wrapped in one. +/// The bridge adapter often wraps file contents in ```` ``` ```` fences. +pub fn strip_outer_code_fence(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.starts_with("```") { + // Find end of first line (the opening fence, possibly with a language tag) + if let Some(first_newline) = trimmed.find('\n') { + let after_opening = &trimmed[first_newline + 1..]; + // Check if it ends with a closing fence + if let Some(body) = after_opening.strip_suffix("```") { + return body.trim_end().to_owned(); + } + // Also handle closing fence followed by newline + let after_trimmed = after_opening.trim_end(); + if let Some(stripped) = after_trimmed.strip_suffix("```") { + return stripped.trim_end().to_owned(); + } + } + } + text.to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + // strip_outer_code_fence + + #[test] + fn strip_fenced_code() { + let input = "```rust\nfn main() {}\n```"; + let result = strip_outer_code_fence(input); + assert_eq!(result, "fn main() {}"); + } + + #[test] + fn strip_fenced_no_lang_tag() { + let input = "```\nhello world\n```"; + let result = strip_outer_code_fence(input); + assert_eq!(result, "hello world"); + } + + #[test] + fn strip_not_fenced_passthrough() { + let input = "just plain text"; + let result = strip_outer_code_fence(input); + assert_eq!(result, "just plain text"); + } + + #[test] + fn strip_fenced_with_trailing_whitespace() { + let input = "```\ncontent\n``` \n"; + let result = strip_outer_code_fence(input); + assert_eq!(result, "content"); + } + + #[test] + fn strip_nested_fences_only_outer() { + let input = "```\ninner ```\nstuff\n```"; + let result = strip_outer_code_fence(input); + assert!(result.contains("inner ```")); + } + + #[test] + fn strip_only_opening_fence() { + let input = "```rust\nfn main() {}"; + let result = strip_outer_code_fence(input); + assert_eq!(result, input); + } + + #[test] + fn strip_empty_fenced_block() { + let input = "```\n```"; + let result = strip_outer_code_fence(input); + assert_eq!(result, ""); + } + + #[test] + fn strip_multiline_content() { + let input = "```python\nline1\nline2\nline3\n```"; + let result = strip_outer_code_fence(input); + assert_eq!(result, "line1\nline2\nline3"); + } + + /// Quadruple backtick fence -- starts with 4 backticks which starts with 3, so it should still work. + #[test] + fn strip_quadruple_backtick_fence() { + let input = "````\ncontent here\n````"; + let result = strip_outer_code_fence(input); + // Starts with ```, so it enters the stripping path. + // Closing is ```` - strip_suffix("```") matches the last 3 backticks + // leaving one ` in the body. Let's just verify it doesn't panic + // and returns something reasonable. + assert!(result.contains("content here")); + } + + /// Tilde fences -- NOT handled by `strip_outer_code_fence` (only checks triple backticks). + #[test] + fn strip_tilde_fence_passthrough() { + let input = "~~~\ncontent\n~~~"; + let result = strip_outer_code_fence(input); + assert_eq!(result, input); + } + + /// Content with inner code fences that look like closing fences. + #[test] + fn strip_inner_fence_in_content() { + let input = "```\nsome code\n```\nmore code\n```"; + let result = strip_outer_code_fence(input); + // The function finds the first newline, then looks for ``` at the end + // of the remaining text. The last ``` is the closing fence. + assert!(result.contains("some code")); + } + + /// Very large content inside fence - stress test. + #[test] + fn strip_large_fenced_content() { + let big: String = (0..10_000).fold(String::new(), |mut s, i| { + use std::fmt::Write; + writeln!(s, "line {i}").unwrap(); + s + }); + let input = format!("```\n{big}```"); + let result = strip_outer_code_fence(&input); + assert!(result.contains("line 0")); + assert!(result.contains("line 9999")); + } + + /// Fence with blank content line. + #[test] + fn strip_fence_with_blank_lines() { + let input = "```\n\n\n\n```"; + let result = strip_outer_code_fence(input); + // Content is three blank lines, trimmed to empty + assert!(result.is_empty() || result.chars().all(|c| c == '\n')); + } + + /// Text starting with triple backticks but not at the beginning (leading whitespace). + #[test] + fn strip_fence_with_leading_whitespace() { + let input = " ```\ncontent\n```"; + let result = strip_outer_code_fence(input); + // After trim(), starts with ```, so should strip + assert_eq!(result, "content"); + } + + #[test] + fn render_diff_includes_repository_label() { + let lines = render_diff( + &model::Diff::new("src/main.rs", "fn main() {}\n") + .old_text(Some("fn old() {}\n")) + .repository(Some("acme/project".to_owned())), + ); + let header: String = lines[0].spans.iter().map(|span| span.content.as_ref()).collect(); + assert!(header.contains("main.rs")); + assert!(header.contains("[acme/project]")); + } + + #[test] + fn looks_like_unified_diff_detects_git_style_payload() { + let raw = "diff --git a/a.rs b/a.rs\nindex 111..222 100644\n--- a/a.rs\n+++ b/a.rs\n@@ -1 +1 @@\n-old\n+new\n"; + assert!(looks_like_unified_diff(raw)); + } + + #[test] + fn render_raw_unified_diff_styles_hunks_and_additions() { + let raw = "--- a/file.rs\n+++ b/file.rs\n@@ -1 +1 @@\n-old\n+new\n"; + let lines = render_raw_unified_diff(raw); + assert_eq!(lines[0].spans[0].style.fg, Some(Color::Red)); + assert_eq!(lines[1].spans[0].style.fg, Some(Color::Green)); + assert_eq!(lines[2].spans[0].style.fg, Some(Color::Cyan)); + assert_eq!(lines[4].spans[0].style.fg, Some(Color::Green)); + } + + // lang_from_title + + #[test] + fn lang_rust_file() { + assert_eq!(lang_from_title("src/main.rs"), "rs"); + } + + #[test] + fn lang_python_with_prefix() { + assert_eq!(lang_from_title("Read foo.py"), "py"); + } + + #[test] + fn lang_toml_file() { + assert_eq!(lang_from_title("Cargo.toml"), "toml"); + } + + #[test] + fn lang_no_extension() { + assert_eq!(lang_from_title("Makefile"), ""); + } + + #[test] + fn lang_empty_title() { + assert_eq!(lang_from_title(""), ""); + } + + #[test] + fn lang_mixed_case() { + assert_eq!(lang_from_title("file.RS"), "rs"); + } + + #[test] + fn lang_multiple_dots() { + assert_eq!(lang_from_title("archive.tar.gz"), "gz"); + } + + #[test] + fn lang_path_with_spaces() { + assert_eq!(lang_from_title("Read some/dir/file.tsx"), "tsx"); + } + + #[test] + fn lang_hidden_file() { + assert_eq!(lang_from_title(".gitignore"), "gitignore"); + } + + /// Multiple extensions chained: picks the final one. + #[test] + fn lang_chained_extensions() { + assert_eq!(lang_from_title("Read a.test.spec.ts"), "ts"); + } + + /// Dot at end of title: extension is empty string. + #[test] + fn lang_dot_at_end() { + // "file." - rsplit('.').next() returns "", which is shorter than token + assert_eq!(lang_from_title("file."), ""); + } + + /// Title with only whitespace. + #[test] + fn lang_whitespace_only() { + assert_eq!(lang_from_title(" "), ""); + } + + /// Title with backslash path (Windows). + #[test] + fn lang_windows_backslash_path() { + // Backslashes are not split by split_whitespace, so the whole path is one token + assert_eq!(lang_from_title("Read src\\main.rs"), "rs"); + } + + // is_markdown_file + + #[test] + fn is_md_file() { + assert!(is_markdown_file("README.md")); + } + + #[test] + fn is_mdx_file() { + assert!(is_markdown_file("component.mdx")); + } + + #[test] + fn is_markdown_ext() { + assert!(is_markdown_file("doc.markdown")); + } + + #[test] + fn is_markdown_case_insensitive() { + assert!(is_markdown_file("README.MD")); + assert!(is_markdown_file("file.Md")); + } + + #[test] + fn is_not_markdown() { + assert!(!is_markdown_file("main.rs")); + assert!(!is_markdown_file("style.css")); + assert!(!is_markdown_file("")); + } + + #[test] + fn is_not_markdown_partial() { + assert!(!is_markdown_file("somemdx")); + } + + /// `.md` in the middle of the name is NOT a markdown extension. + #[test] + fn is_not_markdown_md_in_middle() { + assert!(!is_markdown_file("file.md.bak")); + } + + /// Path with .md extension. + #[test] + fn is_markdown_with_path() { + assert!(is_markdown_file("docs/getting-started.md")); + assert!(is_markdown_file("Read /home/user/notes.md")); + } + + /// `.MARKDOWN` all caps. + #[test] + fn is_markdown_uppercase_full() { + assert!(is_markdown_file("FILE.MARKDOWN")); + } +} diff --git a/claude-code-rust/src/ui/footer.rs b/claude-code-rust/src/ui/footer.rs new file mode 100644 index 0000000..3dd804e --- /dev/null +++ b/claude-code-rust/src/ui/footer.rs @@ -0,0 +1,601 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::agent::model; +use crate::app::{App, MessageBlock, MessageRole}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::theme; + +const FOOTER_PAD: u16 = 2; +const FOOTER_COLUMN_GAP: u16 = 1; +const PRIMARY_ROW_LEFT_MIN_WIDTH: u16 = 24; +const SECONDARY_ROW_LEFT_MIN_WIDTH: u16 = 28; +const MIN_CONTEXT_LOCATION_WIDTH: usize = 10; +const MIN_CONTEXT_BRANCH_WIDTH: usize = 4; +type FooterItem = Option<(String, Color)>; +const FOOTER_CONTEXT_VALUE: Color = Color::Gray; + +pub fn render(frame: &mut Frame, area: Rect, app: &mut App) { + if area.height == 0 { + return; + } + + let padded = Rect { + x: area.x.saturating_add(FOOTER_PAD), + y: area.y, + width: area.width.saturating_sub(FOOTER_PAD * 2), + height: area.height, + }; + + let [first_row, second_row] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(padded); + + let first_line = build_primary_line(app); + render_footer_row( + frame, + first_row, + first_line, + footer_update_hint(app), + PRIMARY_ROW_LEFT_MIN_WIDTH, + ); + + let second_hint = footer_mcp_auth_hint(app); + let (second_left, second_right) = split_footer_columns_hint( + second_row, + second_hint.as_ref().map(|(text, _)| text.as_str()), + SECONDARY_ROW_LEFT_MIN_WIDTH, + ); + frame.render_widget( + Paragraph::new(build_context_line(app, usize::from(second_left.width))), + second_left, + ); + if let Some((hint_text, hint_color)) = second_hint { + render_footer_right_info(frame, second_right, &hint_text, hint_color); + } +} + +fn footer_update_hint(app: &App) -> FooterItem { + let permission_count = pending_permission_request_count(app); + if permission_count > 0 { + return Some((format!("{permission_count} PEND. PERM."), Color::Yellow)); + } + app.update_check_hint.as_ref().map(|hint| (hint.clone(), theme::RUST_ORANGE)) +} + +fn footer_mcp_auth_hint(app: &App) -> FooterItem { + let needs_auth_count = mcp_needs_auth_count(app); + (needs_auth_count > 0 && should_show_startup_mcp_hint(app)) + .then(|| (format!("{needs_auth_count} MCP NEEDS AUTH"), Color::Yellow)) +} + +fn render_footer_row( + frame: &mut Frame, + area: Rect, + left_line: Line<'static>, + right_hint: FooterItem, + left_min_width: u16, +) { + let (left_area, right_area) = split_footer_columns_hint( + area, + right_hint.as_ref().map(|(text, _)| text.as_str()), + left_min_width, + ); + frame.render_widget(Paragraph::new(left_line), left_area); + if let Some((hint_text, hint_color)) = right_hint { + render_footer_right_info(frame, right_area, &hint_text, hint_color); + } +} + +fn split_footer_columns_hint( + area: Rect, + right_text: Option<&str>, + left_min_width: u16, +) -> (Rect, Rect) { + if area.width == 0 { + return (area, zero_width_rect(area)); + } + + let Some(right_text) = right_text else { + return (area, zero_width_rect(area)); + }; + + let left_min_width = left_min_width.min(area.width); + let available_right = + area.width.saturating_sub(left_min_width).saturating_sub(FOOTER_COLUMN_GAP); + if available_right == 0 { + return (area, zero_width_rect(area)); + } + + let natural_right_width = u16::try_from(UnicodeWidthStr::width(right_text)).unwrap_or(u16::MAX); + let right_width = natural_right_width.min(available_right); + if right_width == 0 { + return (area, zero_width_rect(area)); + } + + let left_width = area.width.saturating_sub(right_width).saturating_sub(FOOTER_COLUMN_GAP); + let left = Rect { width: left_width, ..area }; + let right = Rect { + x: left.x.saturating_add(left_width).saturating_add(FOOTER_COLUMN_GAP), + width: right_width, + ..area + }; + (left, right) +} + +fn zero_width_rect(area: Rect) -> Rect { + Rect { x: area.x.saturating_add(area.width), width: 0, ..area } +} + +fn build_primary_line(app: &App) -> Line<'static> { + if let Some(ref mode) = app.mode { + let color = mode_color(&mode.current_mode_id); + let (fast_mode_text, fast_mode_color) = fast_mode_badge(app.fast_mode_state); + Line::from(vec![ + Span::styled("[", Style::default().fg(color)), + Span::styled(mode.current_mode_name.clone(), Style::default().fg(color)), + Span::styled("]", Style::default().fg(color)), + Span::raw(" "), + Span::styled("[", Style::default().fg(fast_mode_color)), + Span::styled(fast_mode_text, Style::default().fg(fast_mode_color)), + Span::styled("]", Style::default().fg(fast_mode_color)), + Span::raw(" "), + Span::styled("?", Style::default().fg(Color::White)), + Span::styled(" : Help", Style::default().fg(theme::DIM)), + ]) + } else { + Line::from(vec![ + Span::styled("?", Style::default().fg(Color::White)), + Span::styled(" : Help", Style::default().fg(theme::DIM)), + ]) + } +} + +fn fit_footer_right_text(text: &str, max_width: usize) -> Option { + if max_width == 0 || text.trim().is_empty() { + return None; + } + + if UnicodeWidthStr::width(text) <= max_width { + return Some(text.to_owned()); + } + + if max_width <= 3 { + return Some(".".repeat(max_width)); + } + + let mut fitted = String::new(); + let mut width: usize = 0; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width.saturating_add(ch_width).saturating_add(3) > max_width { + break; + } + fitted.push(ch); + width = width.saturating_add(ch_width); + } + + if fitted.is_empty() { + return Some("...".to_owned()); + } + fitted.push_str("..."); + Some(fitted) +} + +fn render_footer_right_info(frame: &mut Frame, area: Rect, right_text: &str, right_color: Color) { + if area.width == 0 { + return; + } + let Some(fitted) = fit_footer_right_text(right_text, usize::from(area.width)) else { + return; + }; + + let line = Line::from(Span::styled(fitted, Style::default().fg(right_color))); + frame.render_widget(Paragraph::new(line).alignment(Alignment::Right), area); +} + +fn build_context_line(app: &App, max_width: usize) -> Line<'static> { + let Some((location_value, branch_value)) = context_values(app, max_width) else { + return Line::default(); + }; + + let mut spans = vec![ + Span::styled("Loc: ", Style::default().fg(theme::DIM)), + Span::styled(location_value, Style::default().fg(FOOTER_CONTEXT_VALUE)), + ]; + + if let Some(branch_value) = branch_value { + spans.push(Span::styled(" | ", Style::default().fg(theme::DIM))); + spans.push(Span::styled("Branch: ", Style::default().fg(theme::DIM))); + spans.push(Span::styled(branch_value, Style::default().fg(FOOTER_CONTEXT_VALUE))); + } + + Line::from(spans) +} + +fn context_values(app: &App, max_width: usize) -> Option<(String, Option)> { + const LOCATION_LABEL_WIDTH: usize = 5; + const CONTEXT_SEPARATOR_WIDTH: usize = 5; + const BRANCH_LABEL_WIDTH: usize = 8; + + let location_only_width = max_width.saturating_sub(LOCATION_LABEL_WIDTH); + let branch = app.git_branch.as_deref().filter(|branch| !branch.is_empty()); + + if let Some(branch) = branch { + let fixed_width = LOCATION_LABEL_WIDTH + CONTEXT_SEPARATOR_WIDTH + BRANCH_LABEL_WIDTH; + let available_values = max_width.saturating_sub(fixed_width); + if available_values >= MIN_CONTEXT_LOCATION_WIDTH + MIN_CONTEXT_BRANCH_WIDTH { + let branch_width = UnicodeWidthStr::width(branch) + .min(available_values.saturating_sub(MIN_CONTEXT_LOCATION_WIDTH)); + let branch_value = fit_footer_right_text(branch, branch_width); + let branch_display_width = + branch_value.as_ref().map_or(0, |value| UnicodeWidthStr::width(value.as_str())); + let location_width = available_values.saturating_sub(branch_display_width); + if let Some(location_value) = fit_location_value(&app.cwd, location_width) { + return Some((location_value, branch_value)); + } + } + } + + fit_location_value(&app.cwd, location_only_width).map(|location_value| (location_value, None)) +} + +fn fit_location_value(cwd: &str, max_width: usize) -> Option { + if max_width == 0 { + return None; + } + + for candidate in location_candidates(cwd) { + if UnicodeWidthStr::width(candidate.as_str()) <= max_width { + return Some(candidate); + } + } + + fit_footer_suffix_text(cwd, max_width) +} + +fn location_candidates(cwd: &str) -> Vec { + let mut candidates = Vec::new(); + push_unique(&mut candidates, Some(cwd.to_owned())); + push_unique(&mut candidates, trailing_path_components(cwd, 2)); + push_unique(&mut candidates, trailing_path_components(cwd, 1)); + candidates +} + +fn trailing_path_components(path: &str, count: usize) -> Option { + let separator = if path.contains('\\') { "\\" } else { "/" }; + let components: Vec<&str> = path + .split(['/', '\\']) + .filter(|component| !component.is_empty() && *component != "~") + .collect(); + if components.is_empty() { + return None; + } + let start = components.len().saturating_sub(count); + Some(components[start..].join(separator)) +} + +fn push_unique(candidates: &mut Vec, candidate: Option) { + let Some(candidate) = candidate else { + return; + }; + if !candidate.is_empty() && !candidates.iter().any(|existing| existing == &candidate) { + candidates.push(candidate); + } +} + +fn fit_footer_suffix_text(text: &str, max_width: usize) -> Option { + if max_width == 0 || text.trim().is_empty() { + return None; + } + + if UnicodeWidthStr::width(text) <= max_width { + return Some(text.to_owned()); + } + + if max_width <= 3 { + return Some(".".repeat(max_width)); + } + + let mut fitted = String::new(); + let mut width = 0usize; + for ch in text.chars().rev() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width.saturating_add(ch_width).saturating_add(3) > max_width { + break; + } + fitted.insert(0, ch); + width = width.saturating_add(ch_width); + } + + if fitted.is_empty() { + return Some("...".to_owned()); + } + + Some(format!("...{fitted}")) +} + +fn pending_permission_request_count(app: &App) -> usize { + app.pending_interaction_ids + .iter() + .filter(|tool_id| { + let Some((mi, bi)) = app.lookup_tool_call(tool_id) else { + return false; + }; + matches!( + app.messages.get(mi).and_then(|msg| msg.blocks.get(bi)), + Some(MessageBlock::ToolCall(tc)) if tc.pending_permission.is_some() + ) + }) + .count() +} + +fn mcp_needs_auth_count(app: &App) -> usize { + app.mcp + .servers + .iter() + .filter(|server| { + matches!(server.status, crate::agent::types::McpServerConnectionStatus::NeedsAuth) + }) + .count() +} + +fn should_show_startup_mcp_hint(app: &App) -> bool { + !app.messages + .iter() + .any(|message| matches!(message.role, MessageRole::User | MessageRole::Assistant)) +} + +fn mode_color(mode_id: &str) -> Color { + match mode_id { + "default" => theme::DIM, + "plan" => Color::Blue, + "acceptEdits" => Color::Yellow, + "bypassPermissions" | "dontAsk" => Color::Red, + _ => Color::Magenta, + } +} + +fn fast_mode_badge(state: model::FastModeState) -> (&'static str, Color) { + match state { + model::FastModeState::Off => ("FAST:OFF", theme::DIM), + model::FastModeState::Cooldown => ("FAST:CD", Color::Yellow), + model::FastModeState::On => ("FAST:ON", theme::RUST_ORANGE), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::model; + use crate::agent::types::{McpServerConnectionStatus, McpServerStatus}; + use crate::app::{ + App, BlockCache, ChatMessage, InlinePermission, MessageBlock, MessageRole, + TerminalSnapshotMode, TextBlock, ToolCallInfo, + }; + use tokio::sync::oneshot; + + #[test] + fn split_footer_columns_hint_left_gets_its_minimum() { + let area = Rect::new(0, 0, 80, 1); + let left_min = 24u16; + let (left, right) = split_footer_columns_hint(area, Some("1 PEND. PERM."), left_min); + assert_eq!(left.width + FOOTER_COLUMN_GAP + right.width, 80); + assert!(left.width >= left_min); + } + + #[test] + fn split_footer_columns_hint_reserves_natural_right_width() { + let area = Rect::new(0, 0, 80, 1); + let left_min = 24u16; + let right_text = "1 PEND. PERM."; + let (left, right) = split_footer_columns_hint(area, Some(right_text), left_min); + assert_eq!(right.width, u16::try_from(UnicodeWidthStr::width(right_text)).unwrap()); + assert_eq!(left.width + FOOTER_COLUMN_GAP + right.width, 80); + } + + #[test] + fn split_footer_columns_hint_zero_width() { + let area = Rect::new(0, 0, 0, 1); + let (left, right) = split_footer_columns_hint(area, Some("hint"), 24); + assert_eq!(left.width, 0); + assert_eq!(right.width, 0); + } + + #[test] + fn split_footer_columns_hint_drops_right_when_left_min_cannot_be_preserved() { + let area = Rect::new(0, 0, 24, 1); + let (left, right) = split_footer_columns_hint(area, Some("1 MCP NEEDS AUTH"), 24); + assert_eq!(left.width, 24); + assert_eq!(right.width, 0); + } + + #[test] + fn fit_footer_right_text_truncates_when_needed() { + let text = "Update available: v9.9.9 (current v0.2.0)"; + let fitted = fit_footer_right_text(text, 12).expect("fitted text"); + assert!(fitted.ends_with("...")); + assert!(UnicodeWidthStr::width(fitted.as_str()) <= 12); + } + + #[test] + fn fit_footer_right_text_keeps_prefix() { + let text = "Compacting context now and applying update hint"; + let fitted = fit_footer_right_text(text, 20).expect("fitted text"); + assert!(fitted.starts_with("Compacting")); + assert!(UnicodeWidthStr::width(fitted.as_str()) <= 20); + } + + #[test] + fn fit_footer_suffix_text_keeps_path_tail() { + let text = "~/work/company/claude_rust"; + let fitted = fit_footer_suffix_text(text, 14).expect("fitted text"); + assert!(fitted.starts_with("...")); + assert!(fitted.ends_with("claude_rust")); + assert!(UnicodeWidthStr::width(fitted.as_str()) <= 14); + } + + #[test] + fn footer_update_hint_none_without_hint() { + let app = App::test_default(); + assert_eq!(footer_update_hint(&app), None); + } + + #[test] + fn footer_update_hint_returns_text_when_present() { + let mut app = App::test_default(); + app.update_check_hint = Some("Update available".to_owned()); + assert_eq!( + footer_update_hint(&app), + Some(("Update available".to_owned(), theme::RUST_ORANGE)) + ); + } + + #[test] + fn footer_update_hint_prefers_pending_permission_count() { + let mut app = App::test_default(); + app.update_check_hint = Some("Update available".to_owned()); + let (response_tx, _response_rx) = oneshot::channel(); + app.messages.push(ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(ToolCallInfo { + id: "perm-1".into(), + title: "Read".into(), + sdk_tool_name: "Read".into(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status: model::ToolCallStatus::Pending, + content: vec![], + hidden: false, + terminal_id: None, + terminal_command: None, + terminal_output: None, + terminal_output_len: 0, + terminal_bytes_seen: 0, + terminal_snapshot_mode: TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: Some(InlinePermission { + options: vec![], + response_tx, + selected_index: 0, + focused: true, + }), + pending_question: None, + }))], + usage: None, + }); + app.index_tool_call("perm-1".into(), 0, 0); + app.pending_interaction_ids.push("perm-1".into()); + + assert_eq!(footer_update_hint(&app), Some(("1 PEND. PERM.".to_owned(), Color::Yellow))); + } + + #[test] + fn fast_mode_badge_maps_cooldown_to_cd() { + let (label, _) = fast_mode_badge(model::FastModeState::Cooldown); + assert_eq!(label, "FAST:CD"); + } + + #[test] + fn context_line_includes_loc_only_without_branch() { + let mut app = App::test_default(); + app.cwd = "~/repo".into(); + + let text: String = + build_context_line(&app, 80).spans.iter().map(|span| span.content.as_ref()).collect(); + assert_eq!(text, "Loc: ~/repo"); + } + + #[test] + fn context_line_includes_branch_when_present() { + let mut app = App::test_default(); + app.cwd = "~/repo".into(); + app.git_branch = Some("main".into()); + + let text: String = + build_context_line(&app, 80).spans.iter().map(|span| span.content.as_ref()).collect(); + assert_eq!(text, "Loc: ~/repo | Branch: main"); + } + + #[test] + fn context_line_shortens_location_before_dropping_branch() { + let mut app = App::test_default(); + app.cwd = "~/work/company/claude_rust".into(); + app.git_branch = Some("feature/footer".into()); + + let text: String = + build_context_line(&app, 46).spans.iter().map(|span| span.content.as_ref()).collect(); + assert!(text.contains("Branch:")); + assert!(text.starts_with("Loc: ")); + assert!(!text.contains("~/work/company/claude_rust")); + } + + #[test] + fn context_line_drops_branch_when_width_is_too_tight() { + let mut app = App::test_default(); + app.cwd = "~/work/company/claude_rust".into(); + app.git_branch = Some("feature/footer".into()); + + let text: String = + build_context_line(&app, 24).spans.iter().map(|span| span.content.as_ref()).collect(); + assert!(text.starts_with("Loc: ")); + assert!(!text.contains("Branch:")); + } + + #[test] + fn mcp_auth_hint_shows_needs_auth_count_before_real_chat() { + let mut app = App::test_default(); + app.messages.push(ChatMessage { + role: MessageRole::Welcome, + blocks: vec![MessageBlock::Text(TextBlock::from_complete("welcome"))], + usage: None, + }); + app.mcp.servers.push(McpServerStatus { + name: "calendar".into(), + status: McpServerConnectionStatus::NeedsAuth, + server_info: None, + error: None, + config: None, + scope: None, + tools: vec![], + }); + + assert_eq!( + footer_mcp_auth_hint(&app), + Some(("1 MCP NEEDS AUTH".to_owned(), Color::Yellow)) + ); + } + + #[test] + fn mcp_auth_hint_hides_after_assistant_message() { + let mut app = App::test_default(); + app.messages.push(ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::Text(TextBlock::from_complete("hello"))], + usage: None, + }); + app.mcp.servers.push(McpServerStatus { + name: "calendar".into(), + status: McpServerConnectionStatus::NeedsAuth, + server_info: None, + error: None, + config: None, + scope: None, + tools: vec![], + }); + + assert_eq!(footer_mcp_auth_hint(&app), None); + } +} diff --git a/claude-code-rust/src/ui/help.rs b/claude-code-rust/src/ui/help.rs new file mode 100644 index 0000000..e84b8f0 --- /dev/null +++ b/claude-code-rust/src/ui/help.rs @@ -0,0 +1,898 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::app::{App, AppStatus, FocusOwner, HelpView}; +use crate::ui::theme; +use ratatui::Frame; +use ratatui::layout::Constraint; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, Table}; +use unicode_width::UnicodeWidthStr; + +const COLUMN_GAP: usize = 4; +/// Content lines available in the help panel (excluding padding and borders). +const MAX_ROWS: usize = 10; +const HELP_VERTICAL_PADDING_LINES: usize = 1; +const SUBAGENT_NAME_MIN_WIDTH: usize = 12; +const SUBAGENT_NAME_MAX_WIDTH: usize = 28; +const SUBAGENT_NAME_MAX_SHARE_NUM: usize = 2; +const SUBAGENT_NAME_MAX_SHARE_DEN: usize = 5; +const HELP_PANEL_HEIGHT: u16 = 14; + +pub fn is_active(app: &App) -> bool { + app.is_help_active() +} + +/// Returns the number of items in the current help tab (for key navigation). +pub fn help_item_count(app: &App) -> usize { + build_help_items(app).len() +} + +#[allow(clippy::cast_possible_truncation)] +pub fn compute_height(app: &App, _area_width: u16) -> u16 { + if !is_active(app) { + return 0; + } + HELP_PANEL_HEIGHT +} + +pub(crate) fn sync_geometry_state(app: &mut App, panel_width: u16) { + if !is_active(app) || panel_width == 0 { + app.help_visible_count = 0; + return; + } + + let items = build_help_items(app); + let visible_count = visible_count_for_view(app, &items, panel_width); + if matches!(app.help_view, HelpView::SlashCommands | HelpView::Subagents) { + app.help_dialog.clamp(items.len(), visible_count); + } + app.help_visible_count = visible_count; +} + +#[allow(clippy::cast_possible_truncation)] +pub fn render(frame: &mut Frame, area: Rect, app: &mut App) { + if area.height == 0 || area.width == 0 || !is_active(app) { + return; + } + + let items = build_help_items(app); + if items.is_empty() { + return; + } + + match app.help_view { + HelpView::Keys => render_keys_help(frame, area, app, &items), + HelpView::SlashCommands | HelpView::Subagents => { + render_two_column_help(frame, area, app, &items); + } + } +} + +#[allow(clippy::cast_possible_truncation)] +fn render_keys_help(frame: &mut Frame, area: Rect, app: &App, items: &[(String, String)]) { + let rows = items.len().div_ceil(2).min(MAX_ROWS); + let max_items = rows * 2; + let items = &items[..items.len().min(max_items)]; + let inner_width = area.width.saturating_sub(2) as usize; + let col_width = (inner_width.saturating_sub(COLUMN_GAP)) / 2; + let left_width = col_width; + let right_width = col_width; + + let mut table_rows: Vec> = + Vec::with_capacity(rows + HELP_VERTICAL_PADDING_LINES * 2); + + for _ in 0..HELP_VERTICAL_PADDING_LINES { + table_rows.push(Row::new(vec![Cell::from(Line::default()), Cell::from(Line::default())])); + } + + for row in 0..rows { + let left_idx = row; + let right_idx = row + rows; + + let left = items.get(left_idx).cloned().unwrap_or_default(); + let right = items.get(right_idx).cloned().unwrap_or_default(); + + let left_lines = format_item_cell_lines(&left, left_width); + let right_lines = format_item_cell_lines(&right, right_width); + let row_height = left_lines.len().max(right_lines.len()).max(1); + + table_rows.push( + Row::new(vec![Cell::from(Text::from(left_lines)), Cell::from(Text::from(right_lines))]) + .height(row_height as u16), + ); + } + + for _ in 0..HELP_VERTICAL_PADDING_LINES { + table_rows.push(Row::new(vec![Cell::from(Line::default()), Cell::from(Line::default())])); + } + + let block = Block::default() + .title(help_title(app.help_view)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded); + + let table = Table::new( + table_rows, + [Constraint::Length(left_width as u16), Constraint::Length(right_width as u16)], + ) + .column_spacing(COLUMN_GAP as u16) + .block(block); + + frame.render_widget(table, area); +} + +#[allow(clippy::cast_possible_truncation)] +fn render_two_column_help( + frame: &mut Frame, + area: Rect, + app: &mut App, + items: &[(String, String)], +) { + let inner_width = area.width.saturating_sub(2) as usize; + let (name_width, desc_width) = help_item_column_widths(items, inner_width); + let visible_count = visible_count_for_view(app, items, area.width); + + let start = app.help_dialog.scroll_offset; + let end = (start + visible_count).min(items.len()); + let visible_items = &items[start..end]; + let selected = app.help_dialog.selected; + + // Capacity: items + spacers between items + vertical padding. + let mut table_rows: Vec> = Vec::with_capacity( + visible_count + visible_count.saturating_sub(1) + HELP_VERTICAL_PADDING_LINES * 2, + ); + + for _ in 0..HELP_VERTICAL_PADDING_LINES { + table_rows.push(Row::new(vec![Cell::from(Line::default()), Cell::from(Line::default())])); + } + + for (view_index, (name, description)) in visible_items.iter().enumerate() { + let abs_index = start + view_index; + let is_selected = abs_index == selected; + + let name_style = if is_selected { + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else { + Style::default().add_modifier(Modifier::BOLD) + }; + let desc_style = + if is_selected { Style::default().fg(theme::RUST_ORANGE) } else { Style::default() }; + + let name_lines = wrap_text_lines_styled(name, name_width, name_style); + let desc_lines = wrap_text_lines_styled(description, desc_width, desc_style); + let row_height = name_lines.len().max(desc_lines.len()).max(1); + + table_rows.push( + Row::new(vec![Cell::from(Text::from(name_lines)), Cell::from(Text::from(desc_lines))]) + .height(row_height as u16), + ); + + // Spacer row between items for readability. + if view_index + 1 < visible_count { + table_rows.push( + Row::new(vec![Cell::from(Line::default()), Cell::from(Line::default())]).height(1), + ); + } + } + + for _ in 0..HELP_VERTICAL_PADDING_LINES { + table_rows.push(Row::new(vec![Cell::from(Line::default()), Cell::from(Line::default())])); + } + + let block = Block::default() + .title(help_title(app.help_view)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded); + + let table = Table::new( + table_rows, + [Constraint::Length(name_width as u16), Constraint::Length(desc_width as u16)], + ) + .column_spacing(COLUMN_GAP as u16) + .block(block); + + frame.render_widget(table, area); +} + +fn visible_count_for_view(app: &App, items: &[(String, String)], panel_width: u16) -> usize { + if items.is_empty() || panel_width == 0 { + return 0; + } + + match app.help_view { + HelpView::Keys => items.len().div_ceil(2).min(MAX_ROWS), + HelpView::SlashCommands | HelpView::Subagents => { + let inner_width = panel_width.saturating_sub(2) as usize; + let (name_width, desc_width) = help_item_column_widths(items, inner_width); + compute_visible_count( + items, + app.help_dialog.scroll_offset, + MAX_ROWS, + name_width, + desc_width, + ) + } + } +} + +fn build_help_items(app: &App) -> Vec<(String, String)> { + match app.help_view { + HelpView::Keys => build_key_help_items(app), + HelpView::SlashCommands => build_slash_help_items(app), + HelpView::Subagents => build_subagent_help_items(app), + } +} + +fn build_key_help_items(app: &App) -> Vec<(String, String)> { + if app.status == AppStatus::Connecting { + let mut items = blocked_input_help_items("Unavailable while connecting"); + if app.update_check_hint.is_some() { + items.push(("Ctrl+u".to_owned(), "Hide update hint".to_owned())); + } + return items; + } + if app.status == AppStatus::CommandPending { + let mut items = blocked_input_help_items(&format!( + "Unavailable while command runs ({})", + pending_command_help_label(app) + )); + if app.update_check_hint.is_some() { + items.push(("Ctrl+u".to_owned(), "Hide update hint".to_owned())); + } + return items; + } + if app.status == AppStatus::Error { + let mut items = blocked_input_help_items("Unavailable after error"); + if app.update_check_hint.is_some() { + items.push(("Ctrl+u".to_owned(), "Hide update hint".to_owned())); + } + return items; + } + + let mut items: Vec<(String, String)> = vec![ + // Global + ("Ctrl+c".to_owned(), "Quit".to_owned()), + ("Ctrl+q".to_owned(), "Quit".to_owned()), + ("Ctrl+l".to_owned(), "Redraw screen".to_owned()), + ("Shift+Tab".to_owned(), "Cycle mode".to_owned()), + ("Ctrl+o".to_owned(), "Toggle tool collapse".to_owned()), + ("Ctrl+t".to_owned(), "Toggle todos (when available)".to_owned()), + // Chat scrolling + ("Ctrl+Up/Down".to_owned(), "Scroll chat".to_owned()), + ("Mouse wheel".to_owned(), "Scroll chat".to_owned()), + ]; + if app.update_check_hint.is_some() { + items.push(("Ctrl+u".to_owned(), "Hide update hint".to_owned())); + } + if app.is_compacting { + items.push(("Status".to_owned(), "Compacting context".to_owned())); + } + let focus_owner = app.focus_owner(); + + if app.show_todo_panel && !app.todos.is_empty() { + items.push(("Tab".to_owned(), "Toggle todo focus".to_owned())); + } + + // Input + navigation (active outside todo-list and mention focus) + if focus_owner != FocusOwner::TodoList + && focus_owner != FocusOwner::Mention + && focus_owner != FocusOwner::Help + { + items.push(("Enter".to_owned(), "Send message".to_owned())); + items.push(("Shift+Enter".to_owned(), "Insert newline".to_owned())); + items.push(("Up/Down".to_owned(), "Move cursor / scroll chat".to_owned())); + items.push(("Left/Right".to_owned(), "Move cursor".to_owned())); + items.push(("Ctrl+Left/Right".to_owned(), "Word left/right".to_owned())); + items.push(("Home/End".to_owned(), "Line start/end".to_owned())); + items.push(("Backspace".to_owned(), "Delete before".to_owned())); + items.push(("Delete".to_owned(), "Delete after".to_owned())); + items.push(("Ctrl+Backspace/Delete".to_owned(), "Delete word".to_owned())); + items.push(("Ctrl+z/y".to_owned(), "Undo/redo".to_owned())); + items.push(("Paste".to_owned(), "Insert text".to_owned())); + } + + // Turn control + if matches!(app.status, crate::app::AppStatus::Thinking | crate::app::AppStatus::Running) { + items.push(("Esc".to_owned(), "Cancel current turn".to_owned())); + } else if focus_owner == FocusOwner::TodoList { + items.push(("Esc".to_owned(), "Exit todo focus".to_owned())); + } else { + items.push(("Esc".to_owned(), "No-op (idle)".to_owned())); + } + + // Inline interactions (permissions or questions) + if !app.pending_interaction_ids.is_empty() && focus_owner == FocusOwner::Permission { + if app.pending_interaction_ids.len() > 1 { + items.push(("Up/Down".to_owned(), "Switch prompt focus".to_owned())); + } + if focused_question_prompt(app) { + items.push(("Left/Right".to_owned(), "Move selection".to_owned())); + items.push(("Tab".to_owned(), "Toggle notes editor".to_owned())); + items.push(("Enter".to_owned(), "Confirm answer".to_owned())); + items.push(("Esc".to_owned(), "Cancel prompt".to_owned())); + } else { + items.push(("Left/Right".to_owned(), "Select option".to_owned())); + items.push(("Enter".to_owned(), "Confirm option".to_owned())); + items.push(("Ctrl+y/a/n".to_owned(), "Quick select".to_owned())); + items.push(("Esc".to_owned(), "Reject".to_owned())); + } + } + if focus_owner == FocusOwner::TodoList { + items.push(("Up/Down".to_owned(), "Select todo (todo focus)".to_owned())); + } + + items +} + +fn focused_question_prompt(app: &App) -> bool { + let Some(tool_id) = app.pending_interaction_ids.first() else { + return false; + }; + let Some((mi, bi)) = app.lookup_tool_call(tool_id) else { + return false; + }; + let Some(crate::app::MessageBlock::ToolCall(tc)) = + app.messages.get(mi).and_then(|message| message.blocks.get(bi)) + else { + return false; + }; + tc.pending_question.is_some() +} + +fn blocked_input_help_items(input_line: &str) -> Vec<(String, String)> { + vec![ + ("?".to_owned(), "Toggle help".to_owned()), + ("Ctrl+c".to_owned(), "Quit".to_owned()), + ("Ctrl+q".to_owned(), "Quit".to_owned()), + ("Up/Down".to_owned(), "Scroll chat".to_owned()), + ("Ctrl+Up/Down".to_owned(), "Scroll chat".to_owned()), + ("Mouse wheel".to_owned(), "Scroll chat".to_owned()), + ("Ctrl+l".to_owned(), "Redraw screen".to_owned()), + ("Input keys".to_owned(), input_line.to_owned()), + ] +} + +fn pending_command_help_label(app: &App) -> String { + app.pending_command_label.clone().unwrap_or_else(|| "Processing command...".to_owned()) +} + +fn builtin_slash_help_commands() -> [(&'static str, &'static str); 7] { + [ + ("/config", "Open settings"), + ("/login", "Authenticate with Claude"), + ("/logout", "Sign out of Claude"), + ("/mcp", "Open MCP"), + ("/plugins", "Open plugins"), + ("/status", "Show session status"), + ("/usage", "Open usage"), + ] +} + +fn build_slash_help_items(app: &App) -> Vec<(String, String)> { + use std::collections::BTreeMap; + + let mut rows = Vec::new(); + if app.status == AppStatus::Connecting { + rows.push(("Loading commands...".to_owned(), String::new())); + return rows; + } + if app.status == AppStatus::CommandPending { + rows.push((pending_command_help_label(app), String::new())); + return rows; + } + + let mut commands: BTreeMap = builtin_slash_help_commands() + .into_iter() + .map(|(name, description)| (name.to_owned(), description.to_owned())) + .collect(); + + for cmd in &app.available_commands { + let name = + if cmd.name.starts_with('/') { cmd.name.clone() } else { format!("/{}", cmd.name) }; + match commands.get_mut(&name) { + Some(existing) if !cmd.description.trim().is_empty() => { + existing.clone_from(&cmd.description); + } + Some(_) => {} + None => { + commands.insert(name, cmd.description.clone()); + } + } + } + + if commands.is_empty() { + rows.push(( + "No slash commands advertised".to_owned(), + "Not advertised in this session".to_owned(), + )); + return rows; + } + + for (name, desc) in commands { + let description = + if desc.trim().is_empty() { "No description provided".to_owned() } else { desc }; + rows.push((name, description)); + } + + rows +} + +fn build_subagent_help_items(app: &App) -> Vec<(String, String)> { + let mut rows = Vec::new(); + if app.status == AppStatus::Connecting { + rows.push(("Loading subagents...".to_owned(), String::new())); + return rows; + } + if app.status == AppStatus::CommandPending { + rows.push((pending_command_help_label(app), String::new())); + return rows; + } + + let mut agents: Vec<(String, String)> = app + .available_agents + .iter() + .filter(|agent| !agent.name.trim().is_empty()) + .map(|agent| { + let description = if agent.description.trim().is_empty() { + "No description provided".to_owned() + } else { + agent.description.clone() + }; + let label = match &agent.model { + Some(model) if !model.trim().is_empty() => { + format!("&{}\nModel: {}", agent.name, model.trim()) + } + _ => format!("&{}", agent.name), + }; + (label, description) + }) + .collect(); + + agents.sort_by(|a, b| a.0.cmp(&b.0)); + agents.dedup_by(|a, b| a.0 == b.0); + if agents.is_empty() { + rows.push(( + "No subagents advertised".to_owned(), + "Not advertised in this session".to_owned(), + )); + return rows; + } + + rows.extend(agents); + rows +} + +/// Count how many terminal lines `text` wraps into at the given column `width`. +/// Uses the same splitting logic as `take_prefix_by_width` / `wrap_text_lines_styled`. +fn wrapped_line_count(text: &str, width: usize) -> usize { + if width == 0 || text.is_empty() { + return 1; + } + let mut count = 0; + for segment in text.split('\n') { + if segment.is_empty() { + count += 1; + continue; + } + let mut rest = segment.to_owned(); + while !rest.is_empty() { + let (chunk, remaining) = take_prefix_by_width(&rest, width); + if chunk.is_empty() { + break; + } + count += 1; + rest = remaining; + } + } + count.max(1) +} + +/// Compute how many items (starting from `start`) fit within `available_lines`, +/// accounting for each item's actual wrapped height and 1-line spacers between items. +fn compute_visible_count( + items: &[(String, String)], + start: usize, + available_lines: usize, + name_width: usize, + desc_width: usize, +) -> usize { + let mut used = 0; + let mut count = 0; + + for (name, desc) in items.iter().skip(start) { + let name_h = wrapped_line_count(name, name_width); + let desc_h = wrapped_line_count(desc, desc_width); + let item_h = name_h.max(desc_h).max(1); + + // 1-line spacer before every item except the first. + let spacer = usize::from(count > 0); + + if used + spacer + item_h > available_lines { + break; + } + + used += spacer + item_h; + count += 1; + } + + count.max(1) +} + +fn help_item_column_widths(items: &[(String, String)], inner_width: usize) -> (usize, usize) { + if inner_width == 0 { + return (0, 0); + } + if inner_width <= COLUMN_GAP + 1 { + return (inner_width, 1); + } + + let max_name_width = + items.iter().map(|(name, _)| UnicodeWidthStr::width(name.as_str())).max().unwrap_or(0); + let share_cap = + inner_width.saturating_mul(SUBAGENT_NAME_MAX_SHARE_NUM) / SUBAGENT_NAME_MAX_SHARE_DEN; + let min_name_width = SUBAGENT_NAME_MIN_WIDTH.min(share_cap.max(1)); + let preferred_name_width = + max_name_width.max(min_name_width).min(SUBAGENT_NAME_MAX_WIDTH).min(share_cap.max(1)); + let max_name_fit = inner_width.saturating_sub(COLUMN_GAP + 1); + let name_width = preferred_name_width.clamp(1, max_name_fit.max(1)); + let desc_width = inner_width.saturating_sub(name_width + COLUMN_GAP).max(1); + + (name_width, desc_width) +} + +fn wrap_text_lines_styled(text: &str, width: usize, style: Style) -> Vec> { + if width == 0 || text.is_empty() { + return vec![Line::default()]; + } + + let mut lines = Vec::new(); + for segment in text.split('\n') { + if segment.is_empty() { + lines.push(Line::default()); + continue; + } + + let mut rest = segment.to_owned(); + while !rest.is_empty() { + let (chunk, remaining) = take_prefix_by_width(&rest, width); + if chunk.is_empty() { + break; + } + lines.push(Line::from(Span::styled(chunk, style))); + rest = remaining; + } + } + + if lines.is_empty() { vec![Line::default()] } else { lines } +} + +fn help_title(view: HelpView) -> Line<'static> { + let keys_style = if matches!(view, HelpView::Keys) { + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::DIM) + }; + let slash_style = if matches!(view, HelpView::SlashCommands) { + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::DIM) + }; + let subagent_style = if matches!(view, HelpView::Subagents) { + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::DIM) + }; + + let hint = if matches!(view, HelpView::SlashCommands | HelpView::Subagents) { + " (< > tabs \u{25b2}\u{25bc} scroll)" + } else { + " (< > switch tabs)" + }; + + Line::from(vec![ + Span::styled( + " Help ", + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + ), + Span::styled("[", Style::default().fg(theme::DIM)), + Span::styled("Keys", keys_style), + Span::styled(" | ", Style::default().fg(theme::DIM)), + Span::styled("Slash", slash_style), + Span::styled(" | ", Style::default().fg(theme::DIM)), + Span::styled("Subagents", subagent_style), + Span::styled("]", Style::default().fg(theme::DIM)), + Span::styled(hint, Style::default().fg(theme::DIM)), + ]) +} + +fn format_item_cell_lines(item: &(String, String), width: usize) -> Vec> { + let (label, desc) = item; + if width == 0 { + return vec![Line::default()]; + } + if label.is_empty() && desc.is_empty() { + return vec![Line::default()]; + } + + let label = truncate_to_width(label, width); + let label_width = UnicodeWidthStr::width(label.as_str()); + let sep = " : "; + let sep_width = UnicodeWidthStr::width(sep); + + if desc.is_empty() { + return vec![Line::from(Span::styled( + label, + Style::default().add_modifier(Modifier::BOLD), + ))]; + } + + let mut lines: Vec> = Vec::new(); + let mut rest = desc.to_owned(); + + if label_width + sep_width < width { + let first_desc_width = width - label_width - sep_width; + let (first_chunk, remaining) = take_prefix_by_width(&rest, first_desc_width); + lines.push(Line::from(vec![ + Span::styled(label, Style::default().add_modifier(Modifier::BOLD)), + Span::styled(sep.to_owned(), Style::default().fg(theme::DIM)), + Span::raw(first_chunk), + ])); + rest = remaining; + } else { + lines.push(Line::from(Span::styled(label, Style::default().add_modifier(Modifier::BOLD)))); + } + + while !rest.is_empty() { + let (chunk, remaining) = take_prefix_by_width(&rest, width); + if chunk.is_empty() { + break; + } + lines.push(Line::raw(chunk)); + rest = remaining; + } + + if lines.is_empty() { vec![Line::default()] } else { lines } +} + +fn take_prefix_by_width(text: &str, width: usize) -> (String, String) { + if width == 0 || text.is_empty() { + return (String::new(), text.to_owned()); + } + + let mut used = 0usize; + let mut split_at = 0usize; + for (idx, ch) in text.char_indices() { + let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if used + w > width { + break; + } + used += w; + split_at = idx + ch.len_utf8(); + } + + if split_at == 0 { + return (String::new(), text.to_owned()); + } + + (text[..split_at].to_owned(), text[split_at..].to_owned()) +} + +fn truncate_to_width(text: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(text) <= width { + return text.to_owned(); + } + let mut out = String::new(); + let mut used = 0usize; + for ch in text.chars() { + let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if used + w > width { + break; + } + out.push(ch); + used += w; + } + out +} + +#[cfg(test)] +mod tests { + use super::build_help_items; + use crate::app::{App, AppStatus, FocusTarget, HelpView, TodoItem, TodoStatus}; + + fn has_item(items: &[(String, String)], key: &str, desc: &str) -> bool { + items.iter().any(|(k, d)| k == key && d == desc) + } + + #[test] + fn tab_toggle_only_shown_when_todos_available() { + let mut app = App::test_default(); + let items = build_help_items(&app); + assert!(!has_item(&items, "Tab", "Toggle todo focus")); + + app.show_todo_panel = true; + app.todos.push(TodoItem { + content: "Task".into(), + status: TodoStatus::Pending, + active_form: String::new(), + }); + let items = build_help_items(&app); + assert!(has_item(&items, "Tab", "Toggle todo focus")); + } + + #[test] + fn key_tab_does_not_show_removed_header_shortcut() { + let app = App::test_default(); + let items = build_help_items(&app); + assert!(!has_item(&items, "Ctrl+h", "Toggle header")); + } + + #[test] + fn key_tab_shows_ctrl_u_only_when_update_hint_visible() { + let mut app = App::test_default(); + let items = build_help_items(&app); + assert!(!has_item(&items, "Ctrl+u", "Hide update hint")); + + app.update_check_hint = Some("Update available".into()); + let items = build_help_items(&app); + assert!(has_item(&items, "Ctrl+u", "Hide update hint")); + } + + #[test] + fn permission_navigation_only_shown_when_permission_has_focus() { + let mut app = App::test_default(); + app.pending_interaction_ids = vec!["perm-1".into(), "perm-2".into()]; + + // Without permission focus claim, do not show permission-only arrows. + let items = build_help_items(&app); + assert!(!has_item(&items, "Left/Right", "Select option")); + assert!(!has_item(&items, "Up/Down", "Switch prompt focus")); + + app.claim_focus_target(FocusTarget::Permission); + let items = build_help_items(&app); + assert!(has_item(&items, "Left/Right", "Select option")); + assert!(has_item(&items, "Up/Down", "Switch prompt focus")); + } + + #[test] + fn slash_tab_shows_advertised_commands_with_description() { + let mut app = App::test_default(); + app.help_view = HelpView::SlashCommands; + app.available_commands = vec![ + crate::agent::model::AvailableCommand::new("/help", "Open help"), + crate::agent::model::AvailableCommand::new("memory", ""), + ]; + + let items = build_help_items(&app); + assert!(has_item(&items, "/help", "Open help")); + assert!(has_item(&items, "/memory", "No description provided")); + } + + #[test] + fn slash_tab_shows_local_auth_and_config_commands_without_advertisement() { + let mut app = App::test_default(); + app.help_view = HelpView::SlashCommands; + + let items = build_help_items(&app); + assert!(has_item(&items, "/config", "Open settings")); + assert!(has_item(&items, "/login", "Authenticate with Claude")); + assert!(has_item(&items, "/logout", "Sign out of Claude")); + assert!(has_item(&items, "/mcp", "Open MCP")); + assert!(has_item(&items, "/usage", "Open usage")); + assert!(!has_item( + &items, + "No slash commands advertised", + "Not advertised in this session" + )); + } + + #[test] + fn slash_tab_shows_login_logout_when_advertised() { + let mut app = App::test_default(); + app.help_view = HelpView::SlashCommands; + app.available_commands = vec![ + crate::agent::model::AvailableCommand::new("/login", "Login"), + crate::agent::model::AvailableCommand::new("/logout", "Logout"), + ]; + + let items = build_help_items(&app); + assert!(has_item(&items, "/config", "Open settings")); + assert!(has_item(&items, "/login", "Login")); + assert!(has_item(&items, "/logout", "Logout")); + } + + #[test] + fn slash_tab_shows_loading_commands_while_connecting() { + let mut app = App::test_default(); + app.help_view = HelpView::SlashCommands; + app.status = AppStatus::Connecting; + + let items = build_help_items(&app); + assert!(has_item(&items, "Loading commands...", "")); + assert!(!has_item( + &items, + "No slash commands advertised", + "Not advertised in this session" + )); + } + + #[test] + fn slash_tab_does_not_repeat_tab_navigation_hint() { + let mut app = App::test_default(); + app.help_view = HelpView::SlashCommands; + + let items = build_help_items(&app); + assert!(!has_item(&items, "Left/Right", "Switch help tab")); + } + + #[test] + fn key_tab_connecting_shows_startup_shortcuts_only() { + let mut app = App::test_default(); + app.status = AppStatus::Connecting; + + let items = build_help_items(&app); + assert!(has_item(&items, "?", "Toggle help")); + assert!(has_item(&items, "Ctrl+c", "Quit")); + assert!(has_item(&items, "Ctrl+q", "Quit")); + assert!(has_item(&items, "Up/Down", "Scroll chat")); + assert!(has_item(&items, "Input keys", "Unavailable while connecting")); + assert!(!has_item(&items, "Enter", "Send message")); + } + + #[test] + fn key_tab_error_shows_locked_input_shortcuts() { + let mut app = App::test_default(); + app.status = AppStatus::Error; + + let items = build_help_items(&app); + assert!(has_item(&items, "Ctrl+c", "Quit")); + assert!(has_item(&items, "Ctrl+q", "Quit")); + assert!(has_item(&items, "Up/Down", "Scroll chat")); + assert!(has_item(&items, "Input keys", "Unavailable after error")); + assert!(!has_item(&items, "Enter", "Send message")); + } + + #[test] + fn key_tab_does_not_repeat_tab_navigation_hint() { + let app = App::test_default(); + let items = build_help_items(&app); + assert!(!has_item(&items, "Left/Right", "Switch help tab")); + } + + #[test] + fn subagent_tab_shows_advertised_subagents() { + let mut app = App::test_default(); + app.help_view = HelpView::Subagents; + app.available_agents = vec![ + crate::agent::model::AvailableAgent::new("reviewer", "Review code").model("haiku"), + crate::agent::model::AvailableAgent::new("explore", ""), + ]; + + let items = build_help_items(&app); + assert!(has_item(&items, "&reviewer\nModel: haiku", "Review code")); + assert!(has_item(&items, "&explore", "No description provided")); + } + + #[test] + fn subagent_tab_shows_loading_while_connecting() { + let mut app = App::test_default(); + app.help_view = HelpView::Subagents; + app.status = AppStatus::Connecting; + + let items = build_help_items(&app); + assert!(has_item(&items, "Loading subagents...", "")); + } + + #[test] + fn subagent_tab_does_not_repeat_tab_navigation_hint() { + let mut app = App::test_default(); + app.help_view = HelpView::Subagents; + let items = build_help_items(&app); + assert!(!has_item(&items, "Left/Right", "Switch help tab")); + } +} diff --git a/claude-code-rust/src/ui/highlight.rs b/claude-code-rust/src/ui/highlight.rs new file mode 100644 index 0000000..94154d6 --- /dev/null +++ b/claude-code-rust/src/ui/highlight.rs @@ -0,0 +1,218 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::diff; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use std::sync::LazyLock; +use syntect::easy::HighlightLines; +use syntect::highlighting::{Color as SyntectColor, FontStyle, Theme, ThemeSet}; +use syntect::parsing::{SyntaxReference, SyntaxSet}; +use syntect::util::LinesWithEndings; + +static SYNTAX_SET: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); +static THEME_SET: LazyLock = LazyLock::new(ThemeSet::load_defaults); +static FALLBACK_THEME: LazyLock = LazyLock::new(Theme::default); + +pub(crate) fn strip_ansi(text: &str) -> String { + enum State { + Normal, + Escape, + Csi, + Osc, + OscEscape, + } + + let mut out = String::with_capacity(text.len()); + let mut state = State::Normal; + + for ch in text.chars() { + state = match state { + State::Normal => { + if ch == '\u{1b}' { + State::Escape + } else { + out.push(ch); + State::Normal + } + } + State::Escape => match ch { + '[' => State::Csi, + ']' => State::Osc, + _ => State::Normal, + }, + State::Csi => { + if ('\u{40}'..='\u{7e}').contains(&ch) { + State::Normal + } else { + State::Csi + } + } + State::Osc => match ch { + '\u{07}' => State::Normal, + '\u{1b}' => State::OscEscape, + _ => State::Osc, + }, + State::OscEscape => { + if ch == '\\' { + State::Normal + } else { + State::Osc + } + } + }; + } + + out +} + +pub(crate) fn render_terminal_output(text: &str) -> Vec> { + let stripped = strip_ansi(text); + if diff::looks_like_unified_diff(&stripped) { + return diff::render_raw_unified_diff(&stripped); + } + plain_text_lines(&stripped) +} + +pub(crate) fn highlight_code(text: &str, language: Option<&str>) -> Vec> { + let syntax = + language.and_then(find_syntax).unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); + highlight_with_syntax(text, syntax) +} + +pub(crate) fn highlight_shell_command(text: &str) -> Vec> { + let syntax = find_syntax("bash") + .or_else(|| find_syntax("sh")) + .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); + highlight_single_line(text, syntax) +} + +fn highlight_with_syntax(text: &str, syntax: &SyntaxReference) -> Vec> { + if text.is_empty() { + return vec![Line::default()]; + } + + let mut highlighter = HighlightLines::new(syntax, highlight_theme()); + let mut lines = Vec::new(); + + for raw_line in LinesWithEndings::from(text) { + lines.push(highlight_line(raw_line, &mut highlighter)); + } + + if text.ends_with('\n') { + lines.push(Line::default()); + } + + lines +} + +fn highlight_single_line(text: &str, syntax: &SyntaxReference) -> Vec> { + let line = text.lines().next().unwrap_or(""); + let mut highlighter = HighlightLines::new(syntax, highlight_theme()); + highlight_line(line, &mut highlighter).spans +} + +fn highlight_line(line: &str, highlighter: &mut HighlightLines<'_>) -> Line<'static> { + match highlighter.highlight_line(line, &SYNTAX_SET) { + Ok(ranges) => { + let spans = ranges + .into_iter() + .filter_map(|(style, segment)| { + let content = segment.strip_suffix('\n').unwrap_or(segment); + if content.is_empty() { + None + } else { + Some(Span::styled( + content.to_owned(), + ratatui_style(style.foreground, style.font_style), + )) + } + }) + .collect::>(); + if spans.is_empty() { Line::default() } else { Line::from(spans) } + } + Err(err) => { + tracing::warn!("syntect highlight failed: {err}"); + Line::from(line.trim_end_matches('\n').to_owned()) + } + } +} + +fn plain_text_lines(text: &str) -> Vec> { + if text.is_empty() { + return vec![Line::default()]; + } + let mut lines: Vec> = + text.split('\n').map(|line| Line::from(line.to_owned())).collect(); + if lines.is_empty() { + lines.push(Line::default()); + } + lines +} + +fn find_syntax(language: &str) -> Option<&'static SyntaxReference> { + let token = language.trim(); + if token.is_empty() { + return None; + } + SYNTAX_SET + .find_syntax_by_token(token) + .or_else(|| SYNTAX_SET.find_syntax_by_extension(token)) + .or_else(|| SYNTAX_SET.find_syntax_by_name(token)) +} + +fn highlight_theme() -> &'static Theme { + THEME_SET + .themes + .get("base16-ocean.dark") + .or_else(|| THEME_SET.themes.values().next()) + .unwrap_or(&FALLBACK_THEME) +} + +fn ratatui_style(color: SyntectColor, font_style: FontStyle) -> Style { + let mut style = Style::default().fg(Color::Rgb(color.r, color.g, color.b)); + + if font_style.contains(FontStyle::BOLD) { + style = style.add_modifier(Modifier::BOLD); + } + if font_style.contains(FontStyle::ITALIC) { + style = style.add_modifier(Modifier::ITALIC); + } + if font_style.contains(FontStyle::UNDERLINE) { + style = style.add_modifier(Modifier::UNDERLINED); + } + + style +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_ansi_removes_csi_sequences() { + let input = "\u{1b}[31mred\u{1b}[0m plain"; + assert_eq!(strip_ansi(input), "red plain"); + } + + #[test] + fn strip_ansi_removes_osc_sequences() { + let input = "prefix\u{1b}]0;title\u{07}suffix"; + assert_eq!(strip_ansi(input), "prefixsuffix"); + } + + #[test] + fn highlight_code_preserves_text() { + let rendered = highlight_code("fn main() {}\n", Some("rs")); + let text: String = rendered[0].spans.iter().map(|span| span.content.as_ref()).collect(); + assert!(text.contains("fn")); + assert!(text.contains("main")); + } + + #[test] + fn highlight_shell_command_preserves_command() { + let spans = highlight_shell_command("git diff --stat"); + let text: String = spans.iter().map(|span| span.content.as_ref()).collect(); + assert_eq!(text, "git diff --stat"); + } +} diff --git a/claude-code-rust/src/ui/input.rs b/claude-code-rust/src/ui/input.rs new file mode 100644 index 0000000..67ea0f4 --- /dev/null +++ b/claude-code-rust/src/ui/input.rs @@ -0,0 +1,413 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::app::input::parse_paste_placeholder_ranges; +use crate::app::mention; +use crate::app::subagent; +use crate::app::{App, AppStatus}; +use crate::ui::theme; +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use tui_textarea::TextArea; + +/// Horizontal padding to match header/footer inset. +const INPUT_PAD: u16 = 2; + +/// Extra right-side breathing room so text doesn't touch the padded edge. +const INPUT_RIGHT_PAD: u16 = 1; + +/// Prompt column width: "❯ " = 2 columns (icon + space) +const PROMPT_WIDTH: u16 = 2; + +/// Maximum input area height (lines) to prevent the input from consuming the entire screen. +const MAX_INPUT_HEIGHT: u16 = 12; +const HIGHLIGHT_SLASH_PRIORITY: u8 = 6; +const HIGHLIGHT_MENTION_PRIORITY: u8 = 7; +const HIGHLIGHT_SUBAGENT_PRIORITY: u8 = 8; +const HIGHLIGHT_PASTE_PRIORITY: u8 = 9; + +/// Braille spinner frames (same as message.rs) for the connecting animation. +const SPINNER_FRAMES: &[char] = &[ + '\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}', + '\u{2807}', '\u{280F}', +]; + +/// Height of the login hint banner in lines (0 when no hint is active). +/// Used internally by `visual_line_count` and `render` so the layout +/// calculation and rendering stay in sync. +const LOGIN_HINT_LINES: u16 = 2; +const CANCEL_HINT_LINES: u16 = 1; + +#[derive(Clone, Copy)] +pub(crate) struct InputRenderGeometry { + pub hint_pad: Option, + pub padded: Rect, + pub prompt: Rect, + pub text: Rect, +} + +/// Whether a login hint banner is active. +fn has_login_hint(app: &App) -> bool { + app.login_hint.is_some() +} + +fn has_cancel_hint(app: &App) -> bool { + app.pending_cancel_origin.is_some() +} + +pub(crate) fn hint_line_count(app: &App) -> u16 { + let login = if has_login_hint(app) { LOGIN_HINT_LINES } else { 0 }; + let cancel = if has_cancel_hint(app) { CANCEL_HINT_LINES } else { 0 }; + login + cancel +} + +pub(crate) fn compute_render_geometry(area: Rect, hint_lines: u16) -> InputRenderGeometry { + let (hint_area, input_main_area) = if hint_lines > 0 { + let [hint, main] = + Layout::vertical([Constraint::Length(hint_lines), Constraint::Min(1)]).areas(area); + (Some(hint), main) + } else { + (None, area) + }; + + let hint_pad = hint_area.map(|hint| Rect { + x: hint.x.saturating_add(INPUT_PAD), + y: hint.y, + width: hint.width.saturating_sub(INPUT_PAD * 2 + INPUT_RIGHT_PAD), + height: hint.height, + }); + + let padded = Rect { + x: input_main_area.x.saturating_add(INPUT_PAD), + y: input_main_area.y, + width: input_main_area.width.saturating_sub(INPUT_PAD * 2 + INPUT_RIGHT_PAD), + height: input_main_area.height, + }; + let [prompt, text] = + Layout::horizontal([Constraint::Length(PROMPT_WIDTH), Constraint::Min(1)]).areas(padded); + + InputRenderGeometry { hint_pad, padded, prompt, text } +} + +#[allow(clippy::cast_possible_truncation, clippy::too_many_lines)] +pub fn render(frame: &mut Frame, area: Rect, app: &mut App) { + let hint_lines = hint_line_count(app); + let geometry = compute_render_geometry(area, hint_lines); + + if let Some(hint_pad) = geometry.hint_pad { + let mut next_hint_row = hint_pad.y; + + if let Some(hint) = &app.login_hint { + let lines = vec![ + Line::from(Span::styled( + format!( + "Authentication required: {} -- {}", + hint.method_name, hint.method_description + ), + Style::default().fg(Color::Yellow), + )), + Line::from(Span::styled( + "Type /login to authenticate, or run `claude auth login` in another terminal", + Style::default().fg(theme::DIM), + )), + ]; + let login_area = Rect { + x: hint_pad.x, + y: next_hint_row, + width: hint_pad.width, + height: LOGIN_HINT_LINES, + }; + frame.render_widget(Paragraph::new(lines), login_area); + next_hint_row = next_hint_row.saturating_add(LOGIN_HINT_LINES); + } + + if has_cancel_hint(app) { + let spinner_ch = SPINNER_FRAMES[app.spinner_frame % SPINNER_FRAMES.len()]; + let cancel_line = Line::from(vec![ + Span::styled(format!("{spinner_ch} "), Style::default().fg(theme::DIM)), + Span::styled( + "Cancelling current turn... draft will auto-submit when ready.", + Style::default().fg(theme::DIM), + ), + ]); + let cancel_area = Rect { + x: hint_pad.x, + y: next_hint_row, + width: hint_pad.width, + height: CANCEL_HINT_LINES, + }; + frame.render_widget(Paragraph::new(cancel_line), cancel_area); + } + } + + // During Connecting state, show a spinner with static text + if app.status == AppStatus::Connecting { + let spinner_ch = SPINNER_FRAMES[app.spinner_frame % SPINNER_FRAMES.len()]; + let line = Line::from(vec![ + Span::styled(format!("{spinner_ch} "), Style::default().fg(theme::DIM)), + Span::styled("Connecting to Claude Code...", Style::default().fg(theme::DIM)), + ]); + frame.render_widget(Paragraph::new(line), geometry.padded); + return; + } + + if app.status == AppStatus::CommandPending { + let spinner_ch = SPINNER_FRAMES[app.spinner_frame % SPINNER_FRAMES.len()]; + let label = app.pending_command_label.as_deref().unwrap_or("Processing command..."); + let line = Line::from(vec![ + Span::styled(format!("{spinner_ch} "), Style::default().fg(theme::DIM)), + Span::styled(label.to_owned(), Style::default().fg(theme::DIM)), + ]); + frame.render_widget(Paragraph::new(line), geometry.padded); + return; + } + + if app.status == AppStatus::Error { + let lines = vec![ + Line::from(Span::styled( + "Input disabled due to error", + Style::default().fg(theme::STATUS_ERROR), + )), + Line::from(Span::styled( + "Press Ctrl+Q to quit and try again.", + Style::default().fg(theme::DIM), + )), + ]; + frame.render_widget(Paragraph::new(lines), geometry.padded); + return; + } + + // Render prompt icon + let prompt = Line::from(Span::styled( + format!("{} ", theme::PROMPT_CHAR), + Style::default().fg(theme::RUST_ORANGE), + )); + frame.render_widget(Paragraph::new(prompt), geometry.prompt); + + if geometry.text.width == 0 { + return; + } + + configure_input_textarea(app); + app.rendered_input_area = geometry.text; + if app.selection.is_some_and(|selection| selection.kind == crate::app::SelectionKind::Input) { + refresh_selection_snapshot(app); + } + frame.render_widget(app.input.editor(), geometry.text); + + if let Some(sel) = app.selection + && sel.kind == crate::app::SelectionKind::Input + { + frame.render_widget(SelectionOverlay { selection: sel }, geometry.text); + } +} + +pub(super) fn refresh_selection_snapshot(app: &mut App) { + if !app.selection.is_some_and(|selection| selection.kind == crate::app::SelectionKind::Input) { + return; + } + + let area = app.rendered_input_area; + if area.width == 0 || area.height == 0 { + return; + } + + configure_input_textarea(app); + app.rendered_input_lines = render_lines_from_textarea(app.input.editor(), area); +} + +fn configure_input_textarea(app: &mut App) { + let needs_highlight_update = app.input.highlight_version != app.input.content_version; + + { + let textarea = app.input.editor_mut(); + textarea.set_placeholder_text("Type a message..."); + textarea.set_placeholder_style(Style::default().fg(theme::DIM)); + textarea.set_cursor_line_style(Style::default()); + textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); + } + + if needs_highlight_update { + let lines = app.input.lines().to_vec(); + let textarea = app.input.editor_mut(); + textarea.clear_custom_highlight(); + apply_textarea_highlights(textarea, &lines); + app.input.highlight_version = app.input.content_version; + } +} + +fn apply_textarea_highlights(textarea: &mut TextArea<'_>, lines: &[String]) { + let slash_style = Style::default().fg(theme::SLASH_COMMAND); + let mention_style = Style::default().fg(Color::Cyan); + let subagent_style = Style::default().fg(theme::SUBAGENT_TOKEN); + let paste_style = Style::default().fg(Color::Green); + + for (row, line) in lines.iter().enumerate() { + if let Some((start, end)) = slash_command_range(line) { + textarea.custom_highlight( + ((row, start), (row, end)), + slash_style, + HIGHLIGHT_SLASH_PRIORITY, + ); + } + + for (start, end, _) in mention::find_mention_spans(line) { + textarea.custom_highlight( + ((row, start), (row, end)), + mention_style, + HIGHLIGHT_MENTION_PRIORITY, + ); + } + + for (start, end, _) in subagent::find_subagent_spans(line) { + textarea.custom_highlight( + ((row, start), (row, end)), + subagent_style, + HIGHLIGHT_SUBAGENT_PRIORITY, + ); + } + + for (start, end) in parse_paste_placeholder_ranges(line) { + textarea.custom_highlight( + ((row, start), (row, end)), + paste_style, + HIGHLIGHT_PASTE_PRIORITY, + ); + } + } +} + +fn slash_command_range(line: &str) -> Option<(usize, usize)> { + let start = line.find(|c: char| !c.is_whitespace())?; + if line.as_bytes().get(start).copied() != Some(b'/') { + return None; + } + let rel_end = + line[start..].find(char::is_whitespace).unwrap_or_else(|| line.len().saturating_sub(start)); + let end = start + rel_end; + if end <= start + 1 { + return None; + } + Some((start, end)) +} + +struct SelectionOverlay { + selection: crate::app::SelectionState, +} + +impl Widget for SelectionOverlay { + #[allow(clippy::cast_possible_truncation)] + fn render(self, area: Rect, buf: &mut Buffer) { + let (start, end) = + crate::app::normalize_selection(self.selection.start, self.selection.end); + for row in start.row..=end.row { + let y = area.y.saturating_add(row as u16); + if y >= area.bottom() { + break; + } + let row_start = if row == start.row { start.col } else { 0 }; + let row_end = if row == end.row { end.col } else { area.width as usize }; + for col in row_start..row_end { + let x = area.x.saturating_add(col as u16); + if x >= area.right() { + break; + } + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_style(cell.style().add_modifier(Modifier::REVERSED)); + } + } + } + } +} + +fn render_lines_from_textarea(textarea: &TextArea<'_>, area: Rect) -> Vec { + let mut buf = Buffer::empty(area); + textarea.render(area, &mut buf); + let mut lines = Vec::with_capacity(area.height as usize); + for y in 0..area.height { + let mut line = String::new(); + for x in 0..area.width { + if let Some(cell) = buf.cell((area.x + x, area.y + y)) { + line.push_str(cell.symbol()); + } + } + lines.push(line.trim_end().to_owned()); + } + lines +} + +/// Total visual height for the input area: input lines + hint banners. +/// Called by the layout to allocate the correct input area height. +pub fn visual_line_count(app: &mut App, area_width: u16) -> u16 { + let hint = hint_line_count(app); + let content_width = + area_width.saturating_sub(INPUT_PAD * 2 + INPUT_RIGHT_PAD).saturating_sub(PROMPT_WIDTH); + let input_lines = app.input.measure_visual_lines(content_width, MAX_INPUT_HEIGHT); + hint + input_lines +} + +#[cfg(test)] +mod tests { + use super::{ + CANCEL_HINT_LINES, LOGIN_HINT_LINES, MAX_INPUT_HEIGHT, slash_command_range, + visual_line_count, + }; + use crate::app::subagent::find_subagent_spans; + use crate::app::{App, CancelOrigin, LoginHint}; + + #[test] + fn slash_range_matches_leading_command_token() { + assert_eq!(slash_command_range("/mode plan"), Some((0, 5))); + assert_eq!(slash_command_range(" /mode plan"), Some((2, 7))); + } + + #[test] + fn slash_range_ignores_non_command_lines() { + assert_eq!(slash_command_range("hello /mode"), None); + assert_eq!(slash_command_range("/"), None); + assert_eq!(slash_command_range(" "), None); + } + + #[test] + fn subagent_spans_match_valid_ampersand_tokens() { + let spans = find_subagent_spans("&reviewer and &explore"); + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].2, "reviewer"); + assert_eq!(spans[1].2, "explore"); + } + + #[test] + fn subagent_spans_reject_double_ampersand_shell_syntax() { + let spans = find_subagent_spans("cargo test && cargo clippy"); + assert!(spans.is_empty()); + } + + #[test] + fn visual_line_count_uses_textarea_max_rows() { + let mut app = App::test_default(); + app.input.set_text(&"x".repeat(500)); + assert_eq!(visual_line_count(&mut app, 8), MAX_INPUT_HEIGHT); + } + + #[test] + fn visual_line_count_includes_login_hint_rows() { + let mut app = App::test_default(); + app.login_hint = Some(LoginHint { + method_name: "oauth".to_owned(), + method_description: "Sign in".to_owned(), + }); + assert_eq!(visual_line_count(&mut app, 80), LOGIN_HINT_LINES + 1); + } + + #[test] + fn visual_line_count_includes_cancel_hint_row() { + let mut app = App::test_default(); + app.pending_cancel_origin = Some(CancelOrigin::AutoQueue); + assert_eq!(visual_line_count(&mut app, 80), CANCEL_HINT_LINES + 1); + } +} diff --git a/claude-code-rust/src/ui/layout.rs b/claude-code-rust/src/ui/layout.rs new file mode 100644 index 0000000..87b78e0 --- /dev/null +++ b/claude-code-rust/src/ui/layout.rs @@ -0,0 +1,386 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use ratatui::layout::{Constraint, Layout, Rect}; + +pub struct AppLayout { + pub body: Rect, + pub input_sep: Rect, + /// Area for the todo panel (zero-height when hidden or no todos). + /// Positioned below the input top separator and above the input field. + pub todo: Rect, + pub input: Rect, + pub input_bottom_sep: Rect, + pub help: Rect, + pub footer: Option, +} + +pub fn compute(area: Rect, input_lines: u16, todo_height: u16, help_height: u16) -> AppLayout { + let input_height = input_lines.max(1); + + if area.height < 8 { + // Ultra-compact: no footer, no todo + let [body, input, input_bottom_sep, help] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(input_height), + Constraint::Length(1), + Constraint::Length(help_height), + ]) + .areas(area); + AppLayout { + body, + todo: Rect::new(area.x, input.y, area.width, 0), + input_sep: Rect::new(area.x, input.y, area.width, 0), + input, + input_bottom_sep, + help, + footer: None, + } + } else { + let [body, input_sep, todo, input, input_bottom_sep, help, footer] = Layout::vertical([ + Constraint::Min(3), + Constraint::Length(1), + Constraint::Length(todo_height), + Constraint::Length(input_height), + Constraint::Length(1), + Constraint::Length(help_height), + Constraint::Length(2), + ]) + .areas(area); + AppLayout { body, input_sep, todo, input, input_bottom_sep, help, footer: Some(footer) } + } +} + +#[cfg(test)] +mod tests { + // ===== + // TESTS: 33 + // ===== + + use super::*; + use pretty_assertions::assert_eq; + + fn area(w: u16, h: u16) -> Rect { + Rect::new(0, 0, w, h) + } + + /// Sum all layout area heights (handles optional footer). + fn total_height(layout: &AppLayout) -> u16 { + layout.body.height + + layout.todo.height + + layout.input_sep.height + + layout.input.height + + layout.input_bottom_sep.height + + layout.help.height + + layout.footer.map_or(0, |f| f.height) + } + + /// Collect all non-zero-height areas in top-to-bottom order. + fn visible_areas(layout: &AppLayout) -> Vec { + let mut areas = vec![ + layout.body, + layout.input_sep, + layout.todo, + layout.input, + layout.input_bottom_sep, + layout.help, + ]; + if let Some(f) = layout.footer { + areas.push(f); + } + areas.into_iter().filter(|r| r.height > 0).collect() + } + + /// Assert no vertical overlap and areas are in ascending y-order. + fn assert_no_overlap_and_ordered(layout: &AppLayout) { + let areas = visible_areas(layout); + for i in 1..areas.len() { + let prev = areas[i - 1]; + let curr = areas[i]; + assert!( + prev.y + prev.height <= curr.y, + "Area {i}-1 ({prev:?}) overlaps or is not before area {i} ({curr:?})" + ); + } + } + + // Layout (normal terminal) + + #[test] + fn normal_terminal_has_two_line_footer() { + let layout = compute(area(80, 24), 1, 0, 0); + assert!(layout.footer.is_some()); + assert!(layout.body.height >= 3); + assert_eq!(layout.input_sep.height, 1); + assert_eq!(layout.input.height, 1); + assert_eq!(layout.input_bottom_sep.height, 1); + assert_eq!(layout.footer.unwrap().height, 2); + } + + #[test] + fn normal_all_areas_sum_to_total() { + let layout = compute(area(80, 24), 1, 3, 2); + assert_eq!(total_height(&layout), 24); + } + + // Layout + + #[test] + fn ultra_compact_no_footer() { + let layout = compute(area(80, 6), 1, 0, 0); + assert!(layout.footer.is_none()); + assert_eq!(layout.todo.height, 0); + } + + #[test] + fn ultra_compact_areas_sum_to_total() { + let layout = compute(area(80, 6), 1, 0, 0); + assert_eq!(total_height(&layout), 6); + } + + #[test] + fn todo_panel_gets_requested_height() { + let layout = compute(area(80, 24), 1, 5, 0); + assert_eq!(layout.todo.height, 5); + } + + #[test] + fn zero_todo_height_produces_zero_area() { + let layout = compute(area(80, 24), 1, 0, 0); + assert_eq!(layout.todo.height, 0); + } + + #[test] + fn help_gets_requested_height() { + let layout = compute(area(80, 24), 1, 0, 4); + assert_eq!(layout.help.height, 4); + } + + #[test] + fn multi_line_input() { + let layout = compute(area(80, 24), 5, 0, 0); + assert_eq!(layout.input.height, 5); + } + + #[test] + fn input_lines_zero_clamped_to_one() { + let layout = compute(area(80, 24), 0, 0, 0); + assert_eq!(layout.input.height, 1); + } + + // Layout + + #[test] + fn ultra_compact_threshold_exactly_8() { + let layout = compute(area(80, 8), 1, 0, 0); + assert!(layout.footer.is_some()); + } + + #[test] + fn ultra_compact_threshold_7() { + let layout = compute(area(80, 7), 1, 0, 0); + assert!(layout.footer.is_none()); + } + + #[test] + fn threshold_height_keeps_footer_with_help_present() { + let layout = compute(area(80, 8), 1, 0, 1); + assert!(layout.footer.is_some()); + assert!(layout.footer.unwrap().height > 0); + assert_eq!(layout.help.height, 1); + } + + #[test] + fn large_terminal() { + let layout = compute(area(200, 100), 3, 5, 2); + assert_eq!(total_height(&layout), 100); + assert!(layout.body.height >= 3); + } + + #[test] + fn width_carries_through() { + let layout = compute(area(120, 24), 1, 0, 0); + assert_eq!(layout.body.width, 120); + assert_eq!(layout.input.width, 120); + } + + #[test] + fn no_overlap_between_areas() { + let layout = compute(area(80, 24), 2, 3, 1); + assert_no_overlap_and_ordered(&layout); + } + + #[test] + fn everything_maxed_out() { + let layout = compute(area(80, 24), 3, 5, 3); + assert!(layout.body.height >= 3); + assert_eq!(total_height(&layout), 24); + } + + // offset areas + + /// Area starting at non-zero x/y - layout should respect the offset. + #[test] + fn offset_area_respects_origin() { + let r = Rect::new(10, 5, 80, 24); + let layout = compute(r, 1, 0, 0); + // All areas should have x=10 and width=80 + assert_eq!(layout.body.x, 10); + assert_eq!(layout.input.x, 10); + assert_eq!(layout.body.width, 80); + assert_eq!(layout.body.y, 5); + assert_eq!(total_height(&layout), 24); + } + + /// Compact mode with offset area. + #[test] + fn offset_area_compact() { + let r = Rect::new(5, 10, 60, 6); + let layout = compute(r, 1, 0, 0); + assert!(layout.footer.is_none()); + assert_eq!(layout.body.x, 5); + assert_eq!(total_height(&layout), 6); + } + + // degenerate sizes + + /// Zero-height area - everything gets zero or minimal height. + #[test] + fn zero_height_area() { + let layout = compute(area(80, 0), 1, 0, 0); + assert!(layout.footer.is_none()); + } + + /// Height = 1 - absolute minimum. + #[test] + fn height_one() { + let layout = compute(area(80, 1), 1, 0, 0); + assert!(layout.footer.is_none()); + assert_eq!(total_height(&layout), 1); + } + + /// Height = 2. + #[test] + fn height_two() { + let layout = compute(area(80, 2), 1, 0, 0); + assert_eq!(total_height(&layout), 2); + } + + /// Width = 1 - very narrow terminal. + #[test] + fn width_one() { + let layout = compute(Rect::new(0, 0, 1, 24), 1, 0, 0); + assert_eq!(layout.body.width, 1); + assert_eq!(layout.input.width, 1); + assert_eq!(total_height(&layout), 24); + } + + /// Width = 0. + #[test] + fn width_zero() { + let layout = compute(area(0, 24), 1, 0, 0); + assert_eq!(layout.body.width, 0); + assert_eq!(total_height(&layout), 24); + } + + // input exceeds available space + + /// Input requests more lines than the terminal has rows. + #[test] + fn input_larger_than_terminal() { + let layout = compute(area(80, 10), 50, 0, 0); + assert_eq!(total_height(&layout), 10); + } + + /// Todo + help + input together exceed available space. + #[test] + fn competing_constraints_squeeze_body() { + let layout = compute(area(80, 12), 3, 4, 3); + assert_eq!(total_height(&layout), 12); + } + + // compact mode with extras + + /// Ultra-compact with `help_height` > 0. + #[test] + fn compact_with_help() { + let layout = compute(area(80, 6), 1, 0, 2); + assert!(layout.footer.is_none()); + assert_eq!(layout.help.height, 2); + assert_eq!(total_height(&layout), 6); + } + + /// Ultra-compact with multi-line input. + #[test] + fn compact_with_multiline_input() { + let layout = compute(area(80, 7), 3, 0, 0); + assert!(layout.footer.is_none()); + assert_eq!(layout.input.height, 3); + assert_eq!(total_height(&layout), 7); + } + + // ordering invariants + + /// In normal mode, areas must be in strict top-to-bottom order. + #[test] + fn normal_mode_y_ordering() { + let layout = compute(area(80, 30), 2, 3, 1); + assert_no_overlap_and_ordered(&layout); + } + + /// In compact mode, areas must be in strict top-to-bottom order. + #[test] + fn compact_mode_y_ordering() { + let layout = compute(area(80, 6), 1, 0, 1); + assert_no_overlap_and_ordered(&layout); + } + + /// Footer (when present) must be at the very bottom. + #[test] + fn footer_at_bottom() { + let layout = compute(area(80, 24), 1, 0, 0); + let footer = layout.footer.unwrap(); + assert_eq!(footer.y + footer.height, 24); + } + + /// Body starts immediately after header separator. + #[test] + fn body_starts_at_top_without_header() { + let layout = compute(area(80, 24), 1, 0, 0); + assert_eq!(layout.body.y, 0); + } + + // stress / parametric + + /// Verify invariants hold for many terminal sizes. + #[test] + fn parametric_sizes_invariants() { + for h in [1, 2, 3, 5, 7, 8, 10, 15, 24, 50, 100] { + for w in [1, 10, 80, 200] { + let layout = compute(Rect::new(0, 0, w, h), 1, 0, 0); + assert_eq!(total_height(&layout), h, "Height mismatch for {w}x{h}"); + for a in visible_areas(&layout) { + assert_eq!(a.width, w, "Width mismatch in area {a:?} for {w}x{h}"); + } + } + } + } + + /// Verify invariants with various input/todo/help combinations. + #[test] + fn parametric_features_invariants() { + for input in [0, 1, 3, 10] { + for todo in [0, 2, 5] { + for help in [0, 1, 3] { + let layout = compute(area(80, 30), input, todo, help); + assert_eq!( + total_height(&layout), + 30, + "Height mismatch for input={input} todo={todo} help={help}" + ); + assert_no_overlap_and_ordered(&layout); + } + } + } + } +} diff --git a/claude-code-rust/src/ui/markdown.rs b/claude-code-rust/src/ui/markdown.rs new file mode 100644 index 0000000..426ea53 --- /dev/null +++ b/claude-code-rust/src/ui/markdown.rs @@ -0,0 +1,102 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use std::panic::{self, AssertUnwindSafe}; + +pub(super) fn render_markdown_safe(text: &str, bg: Option) -> Vec> { + render_markdown_safe_with(text, bg, render_with_tui_markdown) +} + +fn render_markdown_safe_with(text: &str, bg: Option, renderer: F) -> Vec> +where + F: FnOnce(&str, Option) -> Vec>, +{ + if let Ok(lines) = panic::catch_unwind(AssertUnwindSafe(|| renderer(text, bg))) { + lines + } else { + tracing::warn!("tui-markdown panic; falling back to plain-text markdown rendering"); + plain_text_fallback(text, bg) + } +} + +fn render_with_tui_markdown(text: &str, bg: Option) -> Vec> { + let rendered = tui_markdown::from_str(text); + rendered + .lines + .into_iter() + .map(|line| { + let owned_spans: Vec> = line + .spans + .into_iter() + .map(|span| { + let style = + if let Some(bg_color) = bg { span.style.bg(bg_color) } else { span.style }; + Span::styled(span.content.into_owned(), style) + }) + .collect(); + let line_style = + if let Some(bg_color) = bg { line.style.bg(bg_color) } else { line.style }; + Line::from(owned_spans).style(line_style) + }) + .collect() +} + +fn plain_text_fallback(text: &str, bg: Option) -> Vec> { + let style = + if let Some(bg_color) = bg { Style::default().bg(bg_color) } else { Style::default() }; + + text.split('\n').map(|line| Line::from(Span::styled(line.to_owned(), style))).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::panic::catch_unwind; + + #[test] + fn render_markdown_safe_handles_checklist_content() { + let lines = render_markdown_safe("- [ ] one\n- [x] two", None); + assert!(!lines.is_empty()); + } + + #[test] + fn render_markdown_safe_handles_requested_task_line() { + let input = "- [ ] Move todos below input top line"; + let lines = render_markdown_safe(input, None); + assert!(!lines.is_empty()); + } + + #[test] + fn render_markdown_safe_does_not_panic_on_weird_inputs() { + let weird_inputs = [ + "- [ ] Move todos below input top line", + "- [ ]\n- [x]\n- [ ]", + "- [x] done\n - [ ] child", + "1. [ ] numbered checklist marker", + "[]()[]()[]()", + "```md\n- [ ] fenced checklist\n```", + "> - [ ] blockquote checklist\n>\n> text", + "# Heading\n- [ ] item\n\n| a | b |\n|---|---|\n| x | y |", + "- [ ] [link](https://example.com) [", + "- [ ] \u{200d}\u{200d}\u{200d}", + ]; + + for input in weird_inputs { + let result = catch_unwind(|| render_markdown_safe(input, None)); + assert!(result.is_ok(), "input triggered panic: {input}"); + assert!(!result.unwrap().is_empty(), "input rendered zero lines: {input}"); + } + } + + #[test] + fn render_markdown_safe_falls_back_when_renderer_panics() { + let lines = render_markdown_safe_with("line1\nline2", None, |_text, _bg| { + panic!("forced renderer panic for fallback path") + }); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].spans[0].content.as_ref(), "line1"); + assert_eq!(lines[1].spans[0].content.as_ref(), "line2"); + } +} diff --git a/claude-code-rust/src/ui/message.rs b/claude-code-rust/src/ui/message.rs new file mode 100644 index 0000000..f3d19e1 --- /dev/null +++ b/claude-code-rust/src/ui/message.rs @@ -0,0 +1,1676 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crate::app::{ + BlockCache, ChatMessage, IncrementalMarkdown, MarkdownRenderKey, MessageBlock, MessageRole, + SystemSeverity, TextBlock, WelcomeBlock, +}; +use crate::ui::tables; +use crate::ui::theme; +use crate::ui::tool_call; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +const SPINNER_FRAMES: &[char] = &[ + '\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}', + '\u{2807}', '\u{280F}', +]; + +const FERRIS_SAYS: &[&str] = &[ + r" --------------------------------- ", + r"< Welcome back to Claude, in Rust! >", + r" --------------------------------- ", + r" \ ", + r" \ ", + r" _~^~^~_ ", + r" \) / o o \ (/", + r" '_ - _' ", + r" / '-----' \ ", +]; + +/// Snapshot of the app state needed by the spinner -- extracted before +/// the message loop so we don't need `&App` (which conflicts with `&mut msg`). +#[derive(Clone, Copy)] +#[allow(clippy::struct_excessive_bools)] +pub struct SpinnerState { + pub frame: usize, + /// True when this message owns the currently active assistant turn. + pub is_active_turn_assistant: bool, + /// True when this message should show the initial empty-turn thinking indicator. + pub show_empty_thinking: bool, + /// True when this message should show the thinking indicator. + pub show_thinking: bool, + /// True when this message should show the subagent-thinking indicator. + pub show_subagent_thinking: bool, + /// True when this message should show the compaction indicator. + pub show_compacting: bool, +} + +struct MessageLayout { + segments: Vec, + height: usize, + wrapped_lines: usize, +} + +impl MessageLayout { + fn new() -> Self { + Self { segments: Vec::new(), height: 0, wrapped_lines: 0 } + } + + fn push_blank(&mut self) { + self.segments.push(MessageLayoutSegment::Blank); + self.height += 1; + } + + fn push_wrapped_line(&mut self, line: Line<'static>, width: u16) { + self.push_wrapped_lines(vec![line], width); + } + + fn push_wrapped_lines(&mut self, lines: Vec>, width: u16) { + let height = rendered_lines_height(&lines, width); + self.push_lines(lines, height, height); + } + + fn push_lines(&mut self, lines: Vec>, height: usize, wrapped_lines: usize) { + if height == 0 { + return; + } + self.segments.push(MessageLayoutSegment::Lines { lines, height }); + self.height += height; + self.wrapped_lines += wrapped_lines; + } + + fn render_into(self, out: &mut Vec>) { + for segment in self.segments { + segment.render_into(out); + } + } +} + +enum MessageLayoutSegment { + Blank, + Lines { lines: Vec>, height: usize }, +} + +impl MessageLayoutSegment { + fn render_into(self, out: &mut Vec>) { + match self { + Self::Blank => out.push(Line::default()), + Self::Lines { lines, .. } => out.extend(lines), + } + } +} + +struct RenderedBlockLayout { + lines: Vec>, + height: usize, + wrapped_lines: usize, +} + +fn assistant_role_label_line() -> Line<'static> { + let spans = vec![Span::styled( + "Claude", + Style::default().fg(theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), + )]; + + Line::from(spans) +} + +/// Render a single chat message into a `Vec`, using per-block caches. +/// Takes `&mut` so block caches can be updated. +/// `spinner` is only used for the "Thinking..." animation on empty assistant messages. +#[allow(dead_code)] +pub fn render_message( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + out: &mut Vec>, +) { + render_message_with_tools_collapsed(msg, spinner, width, false, out); +} + +pub fn render_message_with_tools_collapsed( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + tools_collapsed: bool, + out: &mut Vec>, +) { + render_message_internal(msg, spinner, width, tools_collapsed, true, out); +} + +pub fn render_message_with_tools_collapsed_and_separator( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + tools_collapsed: bool, + include_trailing_separator: bool, + out: &mut Vec>, +) { + render_message_internal(msg, spinner, width, tools_collapsed, include_trailing_separator, out); +} + +fn render_message_internal( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + tools_collapsed: bool, + include_trailing_separator: bool, + out: &mut Vec>, +) { + build_message_layout( + msg, + spinner, + width, + MessageRenderOptions { tools_collapsed, include_trailing_separator }, + None, + ) + .render_into(out); +} + +fn build_message_layout( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + options: MessageRenderOptions, + layout_generation: Option, +) -> MessageLayout { + let mut layout = MessageLayout::new(); + layout.push_wrapped_line(role_label_line(&msg.role), width); + + match msg.role { + MessageRole::Welcome => append_welcome_blocks(msg, width, &mut layout), + MessageRole::User => append_user_blocks(msg, width, &mut layout), + MessageRole::Assistant => append_assistant_blocks( + msg, + spinner, + width, + options.tools_collapsed, + layout_generation, + &mut layout, + ), + MessageRole::System(_) => append_system_blocks(msg, width, &mut layout), + } + + if options.include_trailing_separator { + layout.push_blank(); + } + + layout +} + +fn append_welcome_blocks(msg: &mut ChatMessage, width: u16, layout: &mut MessageLayout) { + for block in &mut msg.blocks { + if let MessageBlock::Welcome(welcome) = block { + let rendered = welcome_block_layout(welcome, width); + layout.push_lines(rendered.lines, rendered.height, rendered.wrapped_lines); + } + } +} + +fn append_user_blocks(msg: &mut ChatMessage, width: u16, layout: &mut MessageLayout) { + for block in &mut msg.blocks { + if let MessageBlock::Text(block) = block { + let trailing_gap = block.trailing_blank_lines(); + let rendered = text_block_layout(block, width, Some(theme::USER_MSG_BG), true); + layout.push_lines(rendered.lines, rendered.height, rendered.wrapped_lines); + for _ in 0..trailing_gap { + layout.push_blank(); + } + } + } +} + +fn append_assistant_blocks( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + tools_collapsed: bool, + layout_generation: Option, + layout: &mut MessageLayout, +) { + if msg.blocks.is_empty() && spinner.show_compacting { + layout.push_wrapped_line(compacting_line(spinner.frame), width); + return; + } + if msg.blocks.is_empty() && spinner.show_empty_thinking { + layout.push_wrapped_line(thinking_line(spinner.frame), width); + return; + } + + let show_compacting = spinner.show_compacting; + let show_subagent_thinking = spinner.show_subagent_thinking && !show_compacting; + let mut prev_was_tool = false; + let mut has_body_content = false; + let mut has_visible_content = false; + for block in &mut msg.blocks { + match block { + MessageBlock::Text(block) => { + if prev_was_tool { + layout.push_blank(); + } + let rendered = assistant_text_block_layout(block, width, !has_visible_content); + let trailing_gap = if !has_visible_content && rendered.height == 0 { + 0 + } else { + block.trailing_blank_lines() + }; + layout.push_lines(rendered.lines, rendered.height, rendered.wrapped_lines); + for _ in 0..trailing_gap { + layout.push_blank(); + } + if rendered.height > 0 { + has_body_content = true; + has_visible_content = true; + } + prev_was_tool = false; + } + MessageBlock::ToolCall(tc) => { + let tc = tc.as_mut(); + if tc.hidden { + continue; + } + if !prev_was_tool && has_body_content { + layout.push_blank(); + } + let mut lines = Vec::new(); + tool_call::render_tool_call_cached_with_tools_collapsed( + tc, + width, + spinner.frame, + tools_collapsed, + &mut lines, + ); + let (height, wrapped_lines) = if let Some(layout_generation) = layout_generation { + tool_call::measure_tool_call_height_cached_with_tools_collapsed( + tc, + width, + spinner.frame, + layout_generation, + tools_collapsed, + ) + } else { + (rendered_lines_height(&lines, width), 0) + }; + layout.push_lines(lines, height, wrapped_lines); + if height > 0 { + has_body_content = true; + } + has_visible_content = true; + prev_was_tool = true; + } + MessageBlock::Welcome(_) => {} + } + } + + if show_compacting { + if has_body_content { + layout.push_blank(); + } + layout.push_wrapped_line(compacting_line(spinner.frame), width); + } else if show_subagent_thinking { + if has_body_content { + layout.push_blank(); + } + layout.push_wrapped_line(subagent_thinking_line(spinner.frame), width); + } + if spinner.show_thinking && !show_subagent_thinking && !show_compacting { + if has_body_content { + layout.push_blank(); + } + layout.push_wrapped_line(thinking_line(spinner.frame), width); + } +} + +fn append_system_blocks(msg: &mut ChatMessage, width: u16, layout: &mut MessageLayout) { + let color = system_severity_color(system_severity_from_role(&msg.role)); + for block in &mut msg.blocks { + if let MessageBlock::Text(block) = block { + let trailing_gap = block.trailing_blank_lines(); + let mut rendered = text_block_layout(block, width, None, false); + tint_lines(&mut rendered.lines, color); + layout.push_lines(rendered.lines, rendered.height, rendered.wrapped_lines); + for _ in 0..trailing_gap { + layout.push_blank(); + } + } + } +} + +fn system_severity_color(severity: SystemSeverity) -> Color { + match severity { + SystemSeverity::Info => theme::DIM, + SystemSeverity::Warning => theme::STATUS_WARNING, + SystemSeverity::Error => theme::STATUS_ERROR, + } +} + +fn system_severity_from_role(role: &MessageRole) -> SystemSeverity { + match role { + MessageRole::System(level) => level.unwrap_or(SystemSeverity::Error), + _ => SystemSeverity::Error, + } +} + +/// Measure message height from block caches + width-aware wrapped heights. +/// Returns `(visual_height_rows, lines_wrapped_for_height_updates)`. +/// +/// Accuracy is preserved because each block height is computed with +/// `Paragraph::line_count(width)` on the exact rendered `Vec`. +pub fn measure_message_height_cached( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + layout_generation: u64, +) -> (usize, usize) { + measure_message_height_cached_with_tools_collapsed( + msg, + spinner, + width, + layout_generation, + false, + ) +} + +pub fn measure_message_height_cached_with_tools_collapsed( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + layout_generation: u64, + tools_collapsed: bool, +) -> (usize, usize) { + measure_message_height_cached_with_tools_collapsed_and_separator( + msg, + spinner, + width, + layout_generation, + tools_collapsed, + true, + ) +} + +pub fn measure_message_height_cached_with_tools_collapsed_and_separator( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + layout_generation: u64, + tools_collapsed: bool, + include_trailing_separator: bool, +) -> (usize, usize) { + let layout = build_message_layout( + msg, + spinner, + width, + MessageRenderOptions { tools_collapsed, include_trailing_separator }, + Some(layout_generation), + ); + (layout.height, layout.wrapped_lines) +} + +/// Render a message while consuming as many whole leading rows as possible. +/// +/// `skip_rows` is measured in wrapped visual rows. We skip entire structural parts +/// (label/separators/full blocks) without rendering them. If skipping lands inside +/// a block, that block is rendered in full and the remaining skip is returned so +/// the caller can apply `Paragraph::scroll()` for exact intra-block offset. +#[allow(dead_code)] +pub fn render_message_from_offset( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + layout_generation: u64, + skip_rows: usize, + out: &mut Vec>, +) -> usize { + render_message_from_offset_with_tools_collapsed( + msg, + spinner, + width, + layout_generation, + false, + skip_rows, + out, + ) +} + +pub fn render_message_from_offset_with_tools_collapsed( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + layout_generation: u64, + tools_collapsed: bool, + skip_rows: usize, + out: &mut Vec>, +) -> usize { + render_message_from_offset_internal( + msg, + spinner, + width, + layout_generation, + MessageRenderOptions { tools_collapsed, include_trailing_separator: true }, + skip_rows, + out, + ) +} + +pub(crate) fn render_message_from_offset_internal( + msg: &mut ChatMessage, + spinner: &SpinnerState, + width: u16, + _layout_generation: u64, + options: MessageRenderOptions, + skip_rows: usize, + out: &mut Vec>, +) -> usize { + let mut remaining_skip = skip_rows; + let mut can_consume_skip = true; + + let layout = build_message_layout(msg, spinner, width, options, None); + render_message_layout_from_offset(layout, out, &mut remaining_skip, &mut can_consume_skip); + remaining_skip +} + +fn render_message_layout_from_offset( + layout: MessageLayout, + out: &mut Vec>, + remaining_skip: &mut usize, + can_consume_skip: &mut bool, +) { + for segment in layout.segments { + match segment { + MessageLayoutSegment::Blank => { + if *can_consume_skip && *remaining_skip > 0 { + *remaining_skip -= 1; + } else { + out.push(Line::default()); + } + } + MessageLayoutSegment::Lines { lines, height, .. } => { + if should_skip_whole_block(height, remaining_skip, can_consume_skip) { + continue; + } + out.extend(lines); + } + } + } +} + +#[derive(Clone, Copy)] +pub(crate) struct MessageRenderOptions { + pub tools_collapsed: bool, + pub include_trailing_separator: bool, +} + +fn rendered_lines_height(lines: &[Line<'static>], width: u16) -> usize { + if lines.is_empty() { + return 0; + } + Paragraph::new(Text::from(lines.to_vec())).wrap(Wrap { trim: false }).line_count(width) +} + +fn welcome_block_layout(block: &mut WelcomeBlock, width: u16) -> RenderedBlockLayout { + let had_height = block.cache.height_at(width).is_some(); + let mut lines = Vec::new(); + render_welcome_cached(block, width, &mut lines); + let height = block.cache.height_at(width).unwrap_or_else(|| { + let height = rendered_lines_height(&lines, width); + block.cache.set_height(height, width); + height + }); + let wrapped_lines = if had_height { 0 } else { lines.len() }; + RenderedBlockLayout { lines, height, wrapped_lines } +} + +fn text_block_layout( + block: &mut TextBlock, + width: u16, + bg: Option, + preserve_newlines: bool, +) -> RenderedBlockLayout { + let had_height = block.cache.height_at(width).is_some(); + let mut lines = Vec::new(); + render_text_block_cached(block, width, bg, preserve_newlines, &mut lines); + let height = block.cache.height_at(width).unwrap_or_else(|| { + let height = rendered_lines_height(&lines, width); + block.cache.set_height(height, width); + height + }); + let wrapped_lines = if had_height { 0 } else { lines.len() }; + RenderedBlockLayout { lines, height, wrapped_lines } +} + +fn assistant_text_block_layout( + block: &mut TextBlock, + width: u16, + trim_leading_blank_lines: bool, +) -> RenderedBlockLayout { + let mut rendered = text_block_layout(block, width, None, false); + + if trim_leading_blank_lines { + let leading_blank_lines = count_leading_blank_lines(&rendered.lines); + if leading_blank_lines > 0 { + rendered.lines.drain(..leading_blank_lines); + rendered.height = rendered.height.saturating_sub(leading_blank_lines); + rendered.wrapped_lines = rendered.wrapped_lines.saturating_sub(leading_blank_lines); + } + } + + rendered +} + +fn count_leading_blank_lines(lines: &[Line<'static>]) -> usize { + lines.iter().take_while(|line| line_is_blank(line)).count() +} + +fn line_is_blank(line: &Line<'_>) -> bool { + line.spans.iter().all(|span| span.content.as_ref().chars().all(char::is_whitespace)) +} + +fn should_skip_whole_block( + block_h: usize, + remaining_skip: &mut usize, + can_consume_skip: &mut bool, +) -> bool { + if !*can_consume_skip { + return false; + } + if *remaining_skip >= block_h { + *remaining_skip -= block_h; + return true; + } + if *remaining_skip > 0 { + // We have to render this block, but keep the remaining intra-block skip + // for Paragraph::scroll(). + *can_consume_skip = false; + } + false +} + +fn role_label_line(role: &MessageRole) -> Line<'static> { + match role { + MessageRole::Welcome => Line::from(Span::styled( + "Overview", + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + )), + MessageRole::User => Line::from(Span::styled( + "User", + Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD), + )), + MessageRole::Assistant => assistant_role_label_line(), + MessageRole::System(_) => system_role_label_line(system_severity_from_role(role)), + } +} + +fn system_role_label_line(severity: SystemSeverity) -> Line<'static> { + let (label, color) = match severity { + SystemSeverity::Info => ("Info", theme::DIM), + SystemSeverity::Warning => ("Warning", theme::STATUS_WARNING), + SystemSeverity::Error => ("Error", theme::STATUS_ERROR), + }; + Line::from(Span::styled(label, Style::default().fg(color).add_modifier(Modifier::BOLD))) +} + +fn thinking_line(frame: usize) -> Line<'static> { + let ch = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]; + Line::from(Span::styled(format!("{ch} Thinking..."), Style::default().fg(theme::DIM))) +} + +fn compacting_line(frame: usize) -> Line<'static> { + let ch = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]; + Line::from(Span::styled( + format!("{ch} Compacting context..."), + Style::default().fg(theme::RUST_ORANGE), + )) +} + +fn subagent_thinking_line(frame: usize) -> Line<'static> { + let ch = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]; + Line::from(vec![ + Span::styled(" \u{2514}\u{2500} ", Style::default().fg(theme::DIM)), + Span::styled(format!("{ch} Thinking..."), Style::default().fg(theme::DIM)), + ]) +} + +fn welcome_lines(block: &WelcomeBlock, _width: u16) -> Vec> { + let pad = " "; + let mut lines = Vec::new(); + for art_line in FERRIS_SAYS { + lines.push(Line::from(Span::styled( + format!("{pad}{art_line}"), + Style::default().fg(theme::RUST_ORANGE), + ))); + } + + lines.push(Line::default()); + lines.push(Line::default()); + + lines.push(Line::from(vec![ + Span::styled(format!("{pad}Model: "), Style::default().fg(theme::DIM)), + Span::styled( + block.model_name.clone(), + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + ), + ])); + lines.push(Line::from(Span::styled( + format!("{pad}cwd: {}", block.cwd), + Style::default().fg(theme::DIM), + ))); + + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + format!( + "{pad}Tips: Enter to send, Shift+Enter for newline, Ctrl+C copies selection or quits" + ), + Style::default().fg(theme::DIM), + ))); + lines.push(Line::default()); + + lines +} + +fn render_welcome_cached(block: &mut WelcomeBlock, width: u16, out: &mut Vec>) { + if let Some(cached_lines) = block.cache.get() { + out.extend_from_slice(cached_lines); + return; + } + + let fresh = welcome_lines(block, width); + let h = { + let _t = crate::perf::start_with("msg::wrap_height", "lines", fresh.len()); + Paragraph::new(Text::from(fresh.clone())).wrap(Wrap { trim: false }).line_count(width) + }; + block.cache.store(fresh); + block.cache.set_height(h, width); + if let Some(stored) = block.cache.get() { + out.extend_from_slice(stored); + } +} + +fn tint_lines(lines: &mut [Line<'static>], color: Color) { + for line in lines { + for span in &mut line.spans { + span.style = span.style.fg(color); + } + } +} + +/// Preprocess markdown that `tui_markdown` doesn't handle well. +/// Headings (`# Title`) become `**Title**` (bold) with a blank line before. +/// Handles variations: `#Title`, `# Title`, ` ## Title `, etc. +/// Links are left as-is -- `tui_markdown` handles `[title](url)` natively. +fn preprocess_markdown(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') { + // Strip all leading '#' characters + let after_hashes = trimmed.trim_start_matches('#'); + // Extract heading content (trim spaces between # and text, and trailing) + let content = after_hashes.trim(); + if !content.is_empty() { + // Blank line before heading for visual separation + if !result.is_empty() && !result.ends_with("\n\n") { + result.push('\n'); + } + result.push_str("**"); + result.push_str(content); + result.push_str("**\n"); + continue; + } + } + result.push_str(line); + result.push('\n'); + } + if !text.ends_with('\n') { + result.pop(); + } + result +} + +/// Render a text block with caching. Uses paragraph-level incremental markdown +/// during streaming to avoid re-parsing the entire text every frame. +/// +/// Cache hierarchy: +/// 1. `BlockCache` (full block) -- hit for completed messages (no changes). +/// 2. `IncrementalMarkdown` (per-paragraph) -- only tail paragraph re-parsed during streaming. +pub(super) fn render_text_cached( + text: &str, + cache: &mut BlockCache, + incr: &mut IncrementalMarkdown, + width: u16, + bg: Option, + preserve_newlines: bool, + out: &mut Vec>, +) { + // Fast path: full block cache is valid (completed message, no changes) + if let Some(cached_lines) = cache.get() { + crate::perf::mark_with("msg::cache_hit", "lines", cached_lines.len()); + out.extend_from_slice(cached_lines); + return; + } + crate::perf::mark("msg::cache_miss"); + + let _t = crate::perf::start("msg::render_text"); + + // Build a render function that handles preprocessing + tui_markdown + let render_fn = |src: &str| -> Vec> { + let mut preprocessed = preprocess_markdown(src); + if preserve_newlines { + preprocessed = force_markdown_line_breaks(&preprocessed); + } + tables::render_markdown_with_tables(&preprocessed, width, bg) + }; + let render_key = MarkdownRenderKey { width, bg, preserve_newlines }; + + // Ensure any previously invalidated paragraph caches are re-rendered + let _ = text; + incr.ensure_rendered(render_key, &render_fn); + + // Render: cached paragraphs + fresh tail + let fresh = incr.lines(render_key, &render_fn); + + // Store in the full block cache with wrapped height. + // For streaming messages this will be invalidated on the next chunk, + // but for completed messages it persists. + let h = { + let _t = crate::perf::start_with("msg::wrap_height", "lines", fresh.len()); + Paragraph::new(Text::from(fresh.clone())).wrap(Wrap { trim: false }).line_count(width) + }; + cache.store(fresh); + cache.set_height(h, width); + if let Some(stored) = cache.get() { + out.extend_from_slice(stored); + } +} + +fn render_text_block_cached( + block: &mut TextBlock, + width: u16, + bg: Option, + preserve_newlines: bool, + out: &mut Vec>, +) { + render_text_cached( + &block.text, + &mut block.cache, + &mut block.markdown, + width, + bg, + preserve_newlines, + out, + ); +} + +/// Convert single line breaks into hard breaks so user-entered newlines persist. +fn force_markdown_line_breaks(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + let mut out = String::with_capacity(text.len()); + for (i, line) in lines.iter().enumerate() { + if !line.is_empty() { + out.push_str(line); + out.push_str(" "); + } + if i + 1 < lines.len() || text.ends_with('\n') { + out.push('\n'); + } + } + if text.ends_with('\n') { + // preserve trailing newline + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ChatMessage, MessageBlock, TextBlock, TextBlockSpacing}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Paragraph, Wrap}; + + // preprocess_markdown + + #[test] + fn preprocess_h1_heading() { + let result = preprocess_markdown("# Hello"); + assert!(result.contains("**Hello**")); + assert!(!result.contains('#')); + } + + #[test] + fn preprocess_h3_heading() { + let result = preprocess_markdown("### Deeply Nested"); + assert!(result.contains("**Deeply Nested**")); + } + + #[test] + fn preprocess_non_heading_passthrough() { + let input = "Just normal text\nwith multiple lines"; + let result = preprocess_markdown(input); + assert_eq!(result, input); + } + + #[test] + fn preprocess_mixed_headings_and_text() { + let input = "# Title\nSome text\n## Subtitle\nMore text"; + let result = preprocess_markdown(input); + assert!(result.contains("**Title**")); + assert!(result.contains("Some text")); + assert!(result.contains("**Subtitle**")); + assert!(result.contains("More text")); + } + + #[test] + fn preprocess_heading_no_space() { + let result = preprocess_markdown("#Title"); + assert!(result.contains("**Title**")); + } + + #[test] + fn preprocess_heading_extra_spaces() { + let result = preprocess_markdown("# Spaced Out "); + assert!(result.contains("**Spaced Out**")); + } + + #[test] + fn preprocess_indented_heading() { + let result = preprocess_markdown(" ## Indented"); + assert!(result.contains("**Indented**")); + } + + #[test] + fn preprocess_empty_heading() { + let result = preprocess_markdown("# "); + assert_eq!(result, "# "); + } + + #[test] + fn preprocess_empty_string() { + assert_eq!(preprocess_markdown(""), ""); + } + + #[test] + fn preprocess_preserves_trailing_newline() { + let result = preprocess_markdown("hello\n"); + assert!(result.ends_with('\n')); + } + + #[test] + fn preprocess_no_trailing_newline() { + let result = preprocess_markdown("hello"); + assert!(!result.ends_with('\n')); + } + + #[test] + fn preprocess_blank_line_before_heading() { + let input = "text\n\n# Heading"; + let result = preprocess_markdown(input); + assert!(!result.contains("\n\n\n")); + assert!(result.contains("**Heading**")); + } + + #[test] + fn preprocess_consecutive_headings() { + let input = "# First\n# Second"; + let result = preprocess_markdown(input); + assert!(result.contains("**First**")); + assert!(result.contains("**Second**")); + } + + #[test] + fn preprocess_hash_in_code_not_heading() { + let result = preprocess_markdown("# actual heading"); + assert!(result.contains("**actual heading**")); + } + + /// H6 heading (6 `#` chars). + #[test] + fn preprocess_h6_heading() { + let result = preprocess_markdown("###### Deep H6"); + assert!(result.contains("**Deep H6**")); + assert!(!result.contains('#')); + } + + /// Heading with markdown formatting inside. + #[test] + fn preprocess_heading_with_bold_inside() { + let result = preprocess_markdown("# **bold** and *italic*"); + assert!(result.contains("****bold** and *italic***")); + } + + /// Heading at end of file with no trailing newline. + #[test] + fn preprocess_heading_at_eof_no_newline() { + let result = preprocess_markdown("text\n# Final"); + assert!(result.contains("**Final**")); + assert!(!result.ends_with('\n')); + } + + /// Only hashes with no text: `###` - content after stripping is empty, passthrough. + #[test] + fn preprocess_only_hashes() { + let result = preprocess_markdown("###"); + assert_eq!(result, "###"); + } + + /// Very long heading. + #[test] + fn preprocess_very_long_heading() { + let long_text = "A".repeat(1000); + let input = format!("# {long_text}"); + let result = preprocess_markdown(&input); + assert!(result.starts_with("**")); + assert!(result.contains(&long_text)); + } + + /// Unicode emoji in heading. + #[test] + fn preprocess_unicode_heading() { + let result = preprocess_markdown("# \u{1F680} Launch \u{4F60}\u{597D}"); + assert!(result.contains("**\u{1F680} Launch \u{4F60}\u{597D}**")); + } + + /// Quoted heading: `> # Heading` - starts with `>` not `#`, so passthrough. + #[test] + fn preprocess_blockquote_heading_passthrough() { + let result = preprocess_markdown("> # Quoted heading"); + // Line starts with `>`, not `#`, so trimmed starts with `>` not `#` + assert!(!result.contains("**")); + assert!(result.contains("> # Quoted heading")); + } + + /// All heading levels in sequence. + #[test] + fn preprocess_all_heading_levels() { + let input = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6"; + let result = preprocess_markdown(input); + for label in ["H1", "H2", "H3", "H4", "H5", "H6"] { + assert!(result.contains(&format!("**{label}**")), "missing {label}"); + } + } + + #[test] + fn welcome_lines_do_not_render_recent_sessions_section() { + let message = ChatMessage::welcome_with_recent( + "claude-sonnet-4-5", + "/cwd", + &[crate::app::RecentSessionInfo { + session_id: "11111111-1111-1111-1111-111111111111".to_owned(), + summary: "Title".to_owned(), + last_modified_ms: 0, + file_size_bytes: 0, + cwd: Some("/a".to_owned()), + git_branch: None, + custom_title: Some("Title".to_owned()), + first_prompt: None, + }], + ); + let MessageBlock::Welcome(block) = &message.blocks[0] else { + panic!("expected welcome block"); + }; + let rendered = welcome_lines(block, 120); + let lines: Vec = rendered + .into_iter() + .map(|line| line.spans.into_iter().map(|s| s.content).collect()) + .collect(); + assert!(!lines.iter().any(|line| line.contains("Recent sessions"))); + } + + // force_markdown_line_breaks + + #[test] + fn force_breaks_adds_trailing_spaces() { + let result = force_markdown_line_breaks("line1\nline2"); + assert!(result.contains("line1 \n")); + assert!(result.contains("line2 ")); + } + + #[test] + fn force_breaks_preserves_trailing_newline() { + let result = force_markdown_line_breaks("hello\n"); + assert!(result.ends_with('\n')); + } + + #[test] + fn force_breaks_empty_lines_no_trailing_spaces() { + let result = force_markdown_line_breaks("a\n\nb"); + let lines: Vec<&str> = result.lines().collect(); + assert_eq!(lines.len(), 3); + assert!(lines[0].ends_with(" ")); + assert_eq!(lines[1], ""); + assert!(lines[2].ends_with(" ")); + } + + #[test] + fn force_breaks_single_line_no_trailing_newline() { + let result = force_markdown_line_breaks("hello"); + assert_eq!(result, "hello "); + } + + #[test] + fn force_breaks_many_consecutive_empty_lines() { + let result = force_markdown_line_breaks("a\n\n\nb"); + let lines: Vec<&str> = result.lines().collect(); + assert_eq!(lines.len(), 4); + } + + /// Empty input. + #[test] + fn force_breaks_empty_input() { + let result = force_markdown_line_breaks(""); + assert_eq!(result, ""); + } + + /// Only empty lines. + #[test] + fn force_breaks_only_empty_lines() { + let result = force_markdown_line_breaks("\n\n\n"); + let lines: Vec<&str> = result.lines().collect(); + // All lines are empty, so no trailing spaces added + for line in &lines { + assert!(line.is_empty(), "empty line got content: {line:?}"); + } + } + + /// Line already ending with two spaces - gets two more. + #[test] + fn force_breaks_already_has_trailing_spaces() { + let result = force_markdown_line_breaks("hello \nworld"); + // "hello " + " " = "hello " + assert!(result.starts_with("hello ")); + } + + /// Single newline (no content). + #[test] + fn force_breaks_single_newline() { + let result = force_markdown_line_breaks("\n"); + // One empty line, should stay empty with trailing newline + assert_eq!(result, "\n"); + } + + fn make_text_message(role: MessageRole, text: &str) -> ChatMessage { + ChatMessage { + role, + blocks: vec![MessageBlock::Text(TextBlock::from_complete(text))], + usage: None, + } + } + + fn make_assistant_split_message(first: &str, second: &str) -> ChatMessage { + ChatMessage { + role: MessageRole::Assistant, + blocks: vec![ + MessageBlock::Text( + TextBlock::from_complete(first) + .with_trailing_spacing(TextBlockSpacing::ParagraphBreak), + ), + MessageBlock::Text(TextBlock::from_complete(second)), + ], + usage: None, + } + } + + fn make_tool_call_info( + id: &str, + sdk_tool_name: &str, + status: crate::agent::model::ToolCallStatus, + text: &str, + ) -> crate::app::ToolCallInfo { + crate::app::ToolCallInfo { + id: id.to_owned(), + title: id.to_owned(), + sdk_tool_name: sdk_tool_name.to_owned(), + raw_input: None, + raw_input_bytes: 0, + output_metadata: None, + status, + content: if text.is_empty() { + Vec::new() + } else { + vec![crate::agent::model::ToolCallContent::from(text.to_owned())] + }, + hidden: false, + terminal_id: None, + terminal_command: None, + terminal_output: None, + terminal_output_len: 0, + terminal_bytes_seen: 0, + terminal_snapshot_mode: crate::app::TerminalSnapshotMode::AppendOnly, + render_epoch: 0, + layout_epoch: 0, + last_measured_width: 0, + last_measured_height: 0, + last_measured_layout_epoch: 0, + last_measured_layout_generation: 0, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + } + } + + fn render_lines_to_strings(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect()) + .collect() + } + + fn make_welcome_message(model_name: &str, cwd: &str) -> ChatMessage { + ChatMessage::welcome(model_name, cwd) + } + + fn idle_spinner() -> SpinnerState { + SpinnerState { + frame: 0, + is_active_turn_assistant: false, + show_empty_thinking: false, + show_thinking: false, + show_subagent_thinking: false, + show_compacting: false, + } + } + + fn ground_truth_height(msg: &mut ChatMessage, spinner: &SpinnerState, width: u16) -> usize { + let mut lines = Vec::new(); + render_message(msg, spinner, width, &mut lines); + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }).line_count(width) + } + + #[test] + fn measure_height_matches_ground_truth_for_long_soft_wrap() { + let text = "A".repeat(500); + let spinner = idle_spinner(); + + let mut measured_msg = make_text_message(MessageRole::User, &text); + let mut truth_msg = make_text_message(MessageRole::User, &text); + + let (h, _) = measure_message_height_cached(&mut measured_msg, &spinner, 32, 1); + let truth = ground_truth_height(&mut truth_msg, &spinner, 32); + + assert_eq!(h, truth); + } + + #[test] + fn user_role_label_wrap_height_matches_ground_truth() { + let spinner = idle_spinner(); + let mut measured_msg = make_text_message(MessageRole::User, "ok"); + let mut truth_msg = make_text_message(MessageRole::User, "ok"); + + let (h, _) = measure_message_height_cached(&mut measured_msg, &spinner, 2, 1); + let truth = ground_truth_height(&mut truth_msg, &spinner, 2); + + assert_eq!(h, truth); + assert!(h >= 3); + } + + #[test] + fn system_role_label_wrap_height_matches_ground_truth() { + let spinner = idle_spinner(); + let mut measured_msg = + make_text_message(MessageRole::System(Some(SystemSeverity::Warning)), "rate limit"); + let mut truth_msg = + make_text_message(MessageRole::System(Some(SystemSeverity::Warning)), "rate limit"); + + let (h, _) = measure_message_height_cached(&mut measured_msg, &spinner, 4, 1); + let truth = ground_truth_height(&mut truth_msg, &spinner, 4); + + assert_eq!(h, truth); + assert!(h >= 4); + } + + #[test] + fn welcome_role_label_wrap_height_matches_ground_truth() { + let spinner = idle_spinner(); + let mut measured_msg = make_welcome_message("claude-sonnet-4-5", "~/project"); + let mut truth_msg = make_welcome_message("claude-sonnet-4-5", "~/project"); + + let (h, _) = measure_message_height_cached(&mut measured_msg, &spinner, 4, 1); + let truth = ground_truth_height(&mut truth_msg, &spinner, 4); + + assert_eq!(h, truth); + } + + #[test] + fn assistant_split_paragraph_renders_visible_blank_line() { + let spinner = idle_spinner(); + let mut msg = make_assistant_split_message("First paragraph", "Second paragraph"); + let mut lines = Vec::new(); + render_message(&mut msg, &spinner, 80, &mut lines); + + assert_eq!( + render_lines_to_strings(&lines), + vec![ + "Claude".to_owned(), + "First paragraph".to_owned(), + String::new(), + "Second paragraph".to_owned(), + String::new(), + ] + ); + } + + #[test] + fn assistant_split_paragraph_height_matches_rendered_gap() { + let spinner = idle_spinner(); + let mut measured = make_assistant_split_message("First paragraph", "Second paragraph"); + let mut truth = make_assistant_split_message("First paragraph", "Second paragraph"); + + let (h, _) = measure_message_height_cached(&mut measured, &spinner, 80, 1); + let truth_h = ground_truth_height(&mut truth, &spinner, 80); + assert_eq!(h, truth_h); + assert_eq!(h, 5); + } + + #[test] + fn assistant_message_can_render_without_trailing_separator() { + let spinner = idle_spinner(); + let mut msg = make_text_message(MessageRole::Assistant, "hello"); + let mut lines = Vec::new(); + + render_message_with_tools_collapsed_and_separator( + &mut msg, &spinner, 80, false, false, &mut lines, + ); + + assert_eq!(render_lines_to_strings(&lines), vec!["Claude".to_owned(), "hello".to_owned()]); + + let (h, _) = measure_message_height_cached_with_tools_collapsed_and_separator( + &mut msg, &spinner, 80, 1, false, false, + ); + assert_eq!(h, 2); + } + + #[test] + fn empty_last_assistant_thinking_omits_trailing_separator() { + let spinner = SpinnerState { + is_active_turn_assistant: true, + show_empty_thinking: true, + ..idle_spinner() + }; + let mut msg = ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }; + let mut lines = Vec::new(); + + render_message_with_tools_collapsed_and_separator( + &mut msg, &spinner, 80, false, false, &mut lines, + ); + + let rendered = render_lines_to_strings(&lines); + assert_eq!(rendered.len(), 2); + assert_eq!(rendered[0], "Claude"); + assert!(rendered[1].contains("Thinking...")); + + let (h, _) = measure_message_height_cached_with_tools_collapsed_and_separator( + &mut msg, &spinner, 80, 1, false, false, + ); + assert_eq!(h, 2); + } + + #[test] + fn empty_last_assistant_thinking_wrap_height_matches_ground_truth() { + let spinner = SpinnerState { + is_active_turn_assistant: true, + show_empty_thinking: true, + ..idle_spinner() + }; + let mut measured_msg = + ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }; + let mut truth_msg = + ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }; + + let (h, _) = measure_message_height_cached_with_tools_collapsed_and_separator( + &mut measured_msg, + &spinner, + 6, + 1, + false, + false, + ); + let mut truth_lines = Vec::new(); + render_message_with_tools_collapsed_and_separator( + &mut truth_msg, + &spinner, + 6, + false, + false, + &mut truth_lines, + ); + let truth = + Paragraph::new(Text::from(truth_lines)).wrap(Wrap { trim: false }).line_count(6); + + assert_eq!(h, truth); + assert!(h > 2); + } + + #[test] + fn empty_last_assistant_compacting_omits_trailing_separator() { + let spinner = SpinnerState { + is_active_turn_assistant: true, + show_compacting: true, + ..idle_spinner() + }; + let mut msg = ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }; + let mut lines = Vec::new(); + + render_message_with_tools_collapsed_and_separator( + &mut msg, &spinner, 80, false, false, &mut lines, + ); + + let rendered = render_lines_to_strings(&lines); + assert_eq!(rendered.len(), 2); + assert_eq!(rendered[0], "Claude"); + assert!(rendered[1].contains("Compacting context...")); + + let (h, _) = measure_message_height_cached_with_tools_collapsed_and_separator( + &mut msg, &spinner, 80, 1, false, false, + ); + assert_eq!(h, 2); + } + + #[test] + fn empty_last_assistant_thinking_offset_render_omits_trailing_separator() { + let spinner = SpinnerState { + is_active_turn_assistant: true, + show_empty_thinking: true, + ..idle_spinner() + }; + let mut msg = ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }; + let mut out = Vec::new(); + + let remaining = render_message_from_offset_internal( + &mut msg, + &spinner, + 80, + 1, + MessageRenderOptions { tools_collapsed: false, include_trailing_separator: false }, + 0, + &mut out, + ); + + assert_eq!(remaining, 0); + let rendered = render_lines_to_strings(&out); + assert_eq!(rendered.len(), 2); + assert_eq!(rendered[0], "Claude"); + assert!(rendered[1].contains("Thinking...")); + } + + #[test] + fn empty_last_assistant_compacting_offset_render_omits_trailing_separator() { + let spinner = SpinnerState { + is_active_turn_assistant: true, + show_compacting: true, + ..idle_spinner() + }; + let mut msg = ChatMessage { role: MessageRole::Assistant, blocks: Vec::new(), usage: None }; + let mut out = Vec::new(); + + let remaining = render_message_from_offset_internal( + &mut msg, + &spinner, + 80, + 1, + MessageRenderOptions { tools_collapsed: false, include_trailing_separator: false }, + 0, + &mut out, + ); + + assert_eq!(remaining, 0); + let rendered = render_lines_to_strings(&out); + assert_eq!(rendered.len(), 2); + assert_eq!(rendered[0], "Claude"); + assert!(rendered[1].contains("Compacting context...")); + } + + #[test] + fn render_from_offset_handles_paragraph_gap_as_structural_rows() { + let spinner = idle_spinner(); + let mut msg = make_assistant_split_message("First paragraph", "Second paragraph"); + let mut out = Vec::new(); + + let remaining = render_message_from_offset(&mut msg, &spinner, 80, 1, 2, &mut out); + + assert_eq!(remaining, 0); + assert_eq!( + render_lines_to_strings(&out), + vec![String::new(), "Second paragraph".to_owned(), String::new()] + ); + } + + #[test] + fn measure_height_matches_ground_truth_after_resize() { + let text = + "This is a single very long line without explicit line breaks to stress soft wrapping." + .repeat(20); + let spinner = idle_spinner(); + + let mut measured_msg = make_text_message(MessageRole::Assistant, &text); + let mut truth_wide = make_text_message(MessageRole::Assistant, &text); + let mut truth_narrow = make_text_message(MessageRole::Assistant, &text); + + let (h_wide, _) = measure_message_height_cached(&mut measured_msg, &spinner, 100, 1); + let wide_truth = ground_truth_height(&mut truth_wide, &spinner, 100); + assert_eq!(h_wide, wide_truth); + + // Reuse the same message to hit width-mismatch cache path. + let (h_narrow, _) = measure_message_height_cached(&mut measured_msg, &spinner, 28, 2); + let narrow_truth = ground_truth_height(&mut truth_narrow, &spinner, 28); + assert_eq!(h_narrow, narrow_truth); + } + + #[test] + fn render_from_offset_can_skip_entire_message() { + let spinner = idle_spinner(); + let mut msg = make_text_message(MessageRole::User, "hello\nworld"); + let mut truth_msg = make_text_message(MessageRole::User, "hello\nworld"); + let total = ground_truth_height(&mut truth_msg, &spinner, 120); + + let mut out = Vec::new(); + let rem = render_message_from_offset(&mut msg, &spinner, 120, 1, total + 3, &mut out); + + assert!(out.is_empty()); + assert_eq!(rem, 3); + } + + #[test] + fn welcome_height_matches_ground_truth() { + let spinner = idle_spinner(); + let mut measured_msg = make_welcome_message("claude-sonnet-4-5", "~/project"); + let mut truth_msg = make_welcome_message("claude-sonnet-4-5", "~/project"); + + let (h, _) = measure_message_height_cached(&mut measured_msg, &spinner, 52, 1); + let truth = ground_truth_height(&mut truth_msg, &spinner, 52); + assert_eq!(h, truth); + } + + #[test] + fn system_warning_severity_renders_warning_label() { + let spinner = idle_spinner(); + let mut msg = make_text_message( + MessageRole::System(Some(SystemSeverity::Warning)), + "Rate limit warning", + ); + let mut lines = Vec::new(); + render_message(&mut msg, &spinner, 120, &mut lines); + let rendered = render_lines_to_strings(&lines); + + assert!(rendered.iter().any(|line| line.contains("Warning"))); + assert!(rendered.iter().any(|line| line.contains("Rate limit warning"))); + } + + #[test] + fn assistant_message_shows_subagent_indicator_when_enabled() { + let spinner = SpinnerState { show_subagent_thinking: true, ..idle_spinner() }; + let mut msg = ChatMessage { + role: MessageRole::Assistant, + blocks: vec![MessageBlock::ToolCall(Box::new(make_tool_call_info( + "task-only", + "Task", + crate::agent::model::ToolCallStatus::InProgress, + "Research project", + )))], + usage: None, + }; + + let mut lines = Vec::new(); + render_message(&mut msg, &spinner, 120, &mut lines); + let rendered = render_lines_to_strings(&lines); + + assert!(rendered.iter().any(|line| line.contains("Thinking..."))); + } + + #[test] + fn assistant_heading_at_start_does_not_render_blank_line_after_label() { + let spinner = idle_spinner(); + let mut msg = make_text_message(MessageRole::Assistant, "\n# Heading\nBody"); + + let mut lines = Vec::new(); + render_message(&mut msg, &spinner, 80, &mut lines); + let rendered = render_lines_to_strings(&lines); + + assert_eq!(rendered[0], "Claude"); + assert!(rendered[1].contains("Heading")); + assert!(!rendered[1].is_empty()); + } + + #[test] + fn assistant_heading_at_start_height_matches_rendered_output() { + let spinner = idle_spinner(); + let mut measured = make_text_message(MessageRole::Assistant, "\n# Heading\nBody"); + let mut truth = make_text_message(MessageRole::Assistant, "\n# Heading\nBody"); + + let (h, _) = measure_message_height_cached(&mut measured, &spinner, 80, 1); + let truth_h = ground_truth_height(&mut truth, &spinner, 80); + + assert_eq!(h, truth_h); + } + + #[test] + fn assistant_heading_at_start_offset_render_omits_leading_blank_row() { + let spinner = idle_spinner(); + let mut msg = make_text_message(MessageRole::Assistant, "\n# Heading\nBody"); + let mut out = Vec::new(); + + let remaining = render_message_from_offset(&mut msg, &spinner, 80, 1, 0, &mut out); + let rendered = render_lines_to_strings(&out); + + assert_eq!(remaining, 0); + assert_eq!(rendered[0], "Claude"); + assert!(rendered[1].contains("Heading")); + assert!(!rendered[1].is_empty()); + } + + #[test] + fn assistant_message_hides_subagent_indicator_when_disabled() { + let spinner = idle_spinner(); + let mut msg = ChatMessage { + role: MessageRole::Assistant, + blocks: vec![ + MessageBlock::ToolCall(Box::new(make_tool_call_info( + "task-main", + "Task", + crate::agent::model::ToolCallStatus::InProgress, + "Research project", + ))), + MessageBlock::ToolCall(Box::new(make_tool_call_info( + "bash-child", + "Bash", + crate::agent::model::ToolCallStatus::InProgress, + "", + ))), + ], + usage: None, + }; + + let mut lines = Vec::new(); + render_message(&mut msg, &spinner, 120, &mut lines); + let rendered = render_lines_to_strings(&lines); + + assert!(!rendered.iter().any(|line| line.contains("Thinking..."))); + } + + #[test] + fn assistant_message_places_subagent_indicator_after_visible_tool_blocks() { + let spinner = SpinnerState { show_subagent_thinking: true, ..idle_spinner() }; + let mut msg = ChatMessage { + role: MessageRole::Assistant, + blocks: vec![ + MessageBlock::ToolCall(Box::new(make_tool_call_info( + "task-main", + "Task", + crate::agent::model::ToolCallStatus::InProgress, + "Research project", + ))), + MessageBlock::ToolCall(Box::new(make_tool_call_info( + "bash-done", + "Bash", + crate::agent::model::ToolCallStatus::Completed, + "", + ))), + ], + usage: None, + }; + + let mut lines = Vec::new(); + render_message(&mut msg, &spinner, 120, &mut lines); + let rendered = render_lines_to_strings(&lines); + + let bash_idx = rendered.iter().position(|line| line.contains("Bash")).expect("bash line"); + let thinking_idx = rendered + .iter() + .position(|line| line.contains("Thinking...")) + .expect("subagent thinking line"); + + assert!(thinking_idx > bash_idx); + } + + #[test] + fn assistant_message_does_not_show_empty_turn_thinking_after_content_exists() { + let spinner = SpinnerState { + is_active_turn_assistant: true, + show_empty_thinking: true, + ..idle_spinner() + }; + let mut msg = make_text_message(MessageRole::Assistant, "done"); + + let mut lines = Vec::new(); + render_message(&mut msg, &spinner, 120, &mut lines); + let rendered = render_lines_to_strings(&lines); + + assert!(!rendered.iter().any(|line| line.contains("Thinking..."))); + } + + #[test] + fn assistant_message_suppresses_thinking_line_while_compacting() { + let spinner = SpinnerState { + is_active_turn_assistant: true, + show_thinking: true, + show_compacting: true, + ..idle_spinner() + }; + let mut msg = make_text_message(MessageRole::Assistant, "done"); + + let mut lines = Vec::new(); + render_message(&mut msg, &spinner, 120, &mut lines); + let rendered = render_lines_to_strings(&lines); + + assert!(rendered.iter().any(|line| line.contains("Compacting context..."))); + assert!(!rendered.iter().any(|line| line.contains("Thinking..."))); + } + + #[test] + fn assistant_offset_render_suppresses_thinking_line_while_compacting() { + let spinner = SpinnerState { + is_active_turn_assistant: true, + show_thinking: true, + show_compacting: true, + ..idle_spinner() + }; + let mut msg = make_text_message(MessageRole::Assistant, "done"); + + let mut lines = Vec::new(); + let remaining = render_message_from_offset(&mut msg, &spinner, 120, 1, 0, &mut lines); + let rendered = render_lines_to_strings(&lines); + + assert_eq!(remaining, 0); + assert!(rendered.iter().any(|line| line.contains("Compacting context..."))); + assert!(!rendered.iter().any(|line| line.contains("Thinking..."))); + } +} diff --git a/claude-code-rust/src/ui/mod.rs b/claude-code-rust/src/ui/mod.rs new file mode 100644 index 0000000..5d58af8 --- /dev/null +++ b/claude-code-rust/src/ui/mod.rs @@ -0,0 +1,50 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +mod autocomplete; +mod chat; +mod chat_view; +mod config; +mod diff; +mod footer; +pub(crate) mod help; +mod highlight; +mod input; +mod layout; +mod markdown; +mod message; +mod tables; +pub mod theme; +mod todo; +mod tool_call; +mod trusted; + +pub use message::{SpinnerState, measure_message_height_cached}; + +use crate::app::ActiveView; +use crate::app::App; +use ratatui::Frame; + +pub fn render(frame: &mut Frame, app: &mut App) { + match app.active_view { + ActiveView::Chat => chat_view::render(frame, app), + ActiveView::Config => config::render(frame, app), + ActiveView::Trusted => trusted::render(frame, app), + } +} + +pub(crate) fn refresh_selection_snapshot(app: &mut App) { + let Some(selection) = app.selection else { + return; + }; + + match (app.active_view, selection.kind) { + (ActiveView::Chat, crate::app::SelectionKind::Chat) => { + chat::refresh_selection_snapshot(app); + } + (ActiveView::Chat, crate::app::SelectionKind::Input) => { + input::refresh_selection_snapshot(app); + } + _ => {} + } +} diff --git a/claude-code-rust/src/ui/tables.rs b/claude-code-rust/src/ui/tables.rs new file mode 100644 index 0000000..219e1fe --- /dev/null +++ b/claude-code-rust/src/ui/tables.rs @@ -0,0 +1,789 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use super::markdown; +use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ColumnAlignment { + Left, + Center, + Right, +} + +#[derive(Clone, Debug, Default)] +struct TableRowAst { + cells: Vec, +} + +#[derive(Clone, Debug)] +struct TableCellAst { + chunks: Vec, + preferred_width: usize, + soft_min_width: usize, +} + +#[derive(Clone, Debug, Default)] +struct TableAst { + header: TableRowAst, + rows: Vec, + alignments: Vec, +} + +#[derive(Clone, Debug)] +struct StyledChunk { + text: String, + style: Style, +} + +enum MarkdownBlock { + Text(String), + Table(TableAst), +} + +#[derive(Clone, Copy)] +struct ColumnMetrics { + preferred: usize, + soft_min: usize, +} + +impl TableAst { + fn column_count(&self) -> usize { + let body_cols = self.rows.iter().map(|row| row.cells.len()).max().unwrap_or(0); + self.header.cells.len().max(self.alignments.len()).max(body_cols) + } +} + +impl TableCellAst { + fn empty() -> Self { + Self { chunks: Vec::new(), preferred_width: 1, soft_min_width: 1 } + } +} + +impl Default for TableCellAst { + fn default() -> Self { + Self::empty() + } +} + +pub fn render_markdown_with_tables( + text: &str, + width: u16, + bg: Option, +) -> Vec> { + let blocks = split_markdown_tables(text); + let mut out = Vec::new(); + for block in blocks { + match block { + MarkdownBlock::Text(chunk) => { + if chunk.trim().is_empty() { + continue; + } + out.extend(markdown::render_markdown_safe(&chunk, bg)); + } + MarkdownBlock::Table(table) => { + if !out.is_empty() { + out.push(Line::default()); + } + out.extend(render_table_lines(&table, width, bg)); + out.push(Line::default()); + } + } + } + out +} + +fn parser_options() -> Options { + let mut options = Options::ENABLE_STRIKETHROUGH; + options.insert(Options::ENABLE_TABLES); + options +} + +fn split_markdown_tables(text: &str) -> Vec { + let mut blocks = Vec::new(); + let mut parser = Parser::new_ext(text, parser_options()).into_offset_iter().peekable(); + let mut text_start = 0usize; + + loop { + let Some((event, range)) = parser.next() else { + break; + }; + if let Event::Start(Tag::Table(alignments)) = event { + if text_start < range.start { + blocks.push(MarkdownBlock::Text(text[text_start..range.start].to_owned())); + } + + let mut table_end = range.end; + let table = parse_table_ast(alignments, &mut parser, &mut table_end); + blocks.push(MarkdownBlock::Table(table)); + text_start = table_end; + } + } + + if text_start < text.len() { + blocks.push(MarkdownBlock::Text(text[text_start..].to_owned())); + } + + blocks +} + +fn parse_table_ast<'input, I>( + alignments: Vec, + parser: &mut std::iter::Peekable, + table_end: &mut usize, +) -> TableAst +where + I: Iterator, std::ops::Range)>, +{ + let mut header = None; + let mut rows = Vec::new(); + let mut current_row: Option = None; + let mut current_cell: Option = None; + let mut in_header = false; + + for (event, range) in parser.by_ref() { + *table_end = (*table_end).max(range.end); + match event { + Event::Start(Tag::TableHead) => in_header = true, + Event::End(TagEnd::TableHead) => { + if let Some(row) = current_row.take() + && !row.cells.is_empty() + && header.is_none() + { + header = Some(row); + } + in_header = false; + } + Event::Start(Tag::TableRow) => current_row = Some(TableRowAst::default()), + Event::End(TagEnd::TableRow) => { + let row = current_row.take().unwrap_or_default(); + if in_header && header.is_none() { + header = Some(row); + } else { + rows.push(row); + } + } + Event::Start(Tag::TableCell) => { + current_row.get_or_insert_with(TableRowAst::default); + current_cell = Some(CellBuilder::new()); + } + Event::End(TagEnd::TableCell) => { + if let Some(cell) = current_cell.take() { + current_row.get_or_insert_with(TableRowAst::default).cells.push(cell.finish()); + } + } + Event::End(TagEnd::Table) => break, + Event::Start(tag) => { + if let Some(cell) = current_cell.as_mut() { + cell.start_tag(&tag); + } + } + Event::End(tag) => { + if let Some(cell) = current_cell.as_mut() { + cell.end_tag(tag); + } + } + Event::Text(text) => { + if let Some(cell) = current_cell.as_mut() { + cell.push_text(text.as_ref()); + } + } + Event::Code(code) => { + if let Some(cell) = current_cell.as_mut() { + cell.push_code(code.as_ref()); + } + } + Event::SoftBreak => { + if let Some(cell) = current_cell.as_mut() { + cell.push_text(" "); + } + } + Event::HardBreak => { + if let Some(cell) = current_cell.as_mut() { + cell.push_text("\n"); + } + } + Event::Html(raw) | Event::InlineHtml(raw) => { + if let Some(cell) = current_cell.as_mut() { + cell.push_text(raw.as_ref()); + } + } + Event::InlineMath(math) | Event::DisplayMath(math) => { + if let Some(cell) = current_cell.as_mut() { + cell.push_text(math.as_ref()); + } + } + Event::FootnoteReference(reference) => { + if let Some(cell) = current_cell.as_mut() { + cell.push_text(reference.as_ref()); + } + } + Event::TaskListMarker(done) => { + if let Some(cell) = current_cell.as_mut() { + cell.push_text(if done { "[x] " } else { "[ ] " }); + } + } + Event::Rule => {} + } + } + + TableAst { + header: header.unwrap_or_default(), + rows, + alignments: alignments.into_iter().map(ColumnAlignment::from).collect(), + } +} + +struct CellBuilder { + chunks: Vec, + current_text: String, + style_stack: Vec