diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 11c59924c3..97df67c135 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -70,3 +70,4 @@ aneym dhh caner-akca markjaquith +JJLiebig diff --git a/.github/MAINTAINERS b/.github/MAINTAINERS index c74a23096d..8cdee497c1 100644 --- a/.github/MAINTAINERS +++ b/.github/MAINTAINERS @@ -1,5 +1,5 @@ # GitHub usernames with Herdr maintainer authority, one per line. # Maintainers must also use the canonical repository and have write access. ogulcancelik -Pimpmuckl kangal-bot +JJLiebig diff --git a/.github/workflows/build-artifacts-manual.yml b/.github/workflows/build-artifacts-manual.yml index e5cf8b4b21..a42d38ba30 100644 --- a/.github/workflows/build-artifacts-manual.yml +++ b/.github/workflows/build-artifacts-manual.yml @@ -70,7 +70,7 @@ jobs: targets: ${{ matrix.target }} - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 @@ -154,7 +154,7 @@ jobs: targets: ${{ matrix.target }} - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 @@ -207,7 +207,7 @@ jobs: targets: x86_64-pc-windows-msvc - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a481594e90..ed8f969547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,11 @@ env: # Keep file/line backtraces without generating type and variable debug information. CARGO_PROFILE_DEV_DEBUG: "1" CARGO_PROFILE_TEST_DEBUG: "1" + # vercel-labs/setup-zig does not cache the Zig global cache, so redirect it into the + # workspace .zig-cache that the Zig build cache steps persist. Remove this (and the + # widened condition on "Restore Zig build cache") if we switch to a setup action that + # owns Zig caching, e.g. step-security/setup-zig. See PR #4023. + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -89,12 +94,9 @@ jobs: bun-version: 1.3.14 - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 - use-cache: ${{ runner.os == 'Linux' }} - # Linux builds both SIMD/ReleaseFast and scalar/ReleaseSafe variants. - cache-size-limit: 4096 # Zig's C-header cache keys include the compiler installation path. - name: Stabilize Zig installation path on Unix @@ -116,8 +118,7 @@ jobs: "ZIG=$(Join-Path $stable 'zig.exe')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Restore Zig build cache - if: runner.os == 'macOS' || matrix.kind == 'windows' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .zig-cache key: zig-build-v1-${{ runner.os }}-${{ runner.arch }}-0.16.0-${{ github.run_id }}-${{ github.run_attempt }} @@ -134,7 +135,7 @@ jobs: # rebuilds workspace targets; only rustc's validated query state is retained. - name: Restore Windows incremental compiler state if: matrix.kind == 'windows' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: target/debug/incremental key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}-${{ github.sha }} @@ -192,10 +193,9 @@ jobs: targets: x86_64-pc-windows-msvc - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 - use-cache: false - name: Stabilize Zig installation path shell: pwsh @@ -207,7 +207,7 @@ jobs: "ZIG=$(Join-Path $stable 'zig.exe')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Restore Zig build cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .zig-cache key: zig-build-v1-conpty-package-windows-2022-${{ runner.arch }}-0.16.0-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 880338c7b9..393dfb2104 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -103,7 +103,7 @@ jobs: - name: Install Zig if: steps.plan.outputs.should_publish == 'true' - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 @@ -161,7 +161,7 @@ jobs: targets: ${{ matrix.target }} - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdf0a45fe3..5344962c6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: targets: ${{ matrix.target }} - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 diff --git a/distribution/agent-detection/index.toml b/distribution/agent-detection/index.toml index d10ba8e0ac..1cd6c020de 100644 --- a/distribution/agent-detection/index.toml +++ b/distribution/agent-detection/index.toml @@ -56,6 +56,10 @@ path = "kimi.toml" id = "kiro" path = "kiro.toml" +[[agents]] +id = "letta" +path = "letta.toml" + [[agents]] id = "maki" path = "maki.toml" diff --git a/distribution/agent-detection/letta.toml b/distribution/agent-detection/letta.toml new file mode 100644 index 0000000000..e27e2aa722 --- /dev/null +++ b/distribution/agent-detection/letta.toml @@ -0,0 +1,95 @@ +id = "letta" +version = "2026.08.24.1" +min_engine_version = 3 +updated_at = "2026-08-24T00:00:00Z" +aliases = ["letta-code", "letta code"] + +# Letta keeps completed tool rows and spinners in the transcript. A standalone +# screen spinner is not working evidence. Prefer its live OSC title/progress +# signals, with visible status chrome and running tool rows as fallbacks. +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1400 +region = "osc_progress" +visible_blocker = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +regex = ['^\[ [!.] \] Action Required(?: \| |$)'] + +[[rules]] +id = "command_approval" +state = "blocked" +priority = 1200 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Run this command?", "Enter to select · Esc to cancel"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "active_status" +state = "working" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] + +[[rules]] +id = "running_tool" +state = "working" +priority = 800 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] + +[[rules]] +id = "profile_selector" +state = "unknown" +priority = 700 +region = "bottom_non_empty_lines(12)" +contains = ["Create a new agent (--new)", "Enter select · Esc exit"] + +[[rules]] +id = "composer_input" +state = "unknown" +priority = 150 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*›\s+\S.*$'] +not = [ + { line_regex = ['^\s*›\s+Try\s+"'] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(8)" +visible_idle = true +any = [ + { line_regex = ['^\s*›\s*$'] }, + { line_regex = ['^\s*›\s+Try\s+"'] }, +] +not = [ + { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] }, + { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] }, +] + +[[rules]] +id = "no_live_state_evidence" +state = "unknown" +priority = 0 +region = "whole_recent" +regex = ['(?s)^.*$'] diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index b3990e9697..26299e79c3 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -5867,6 +5867,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "pane.link.resolve", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneLinkActivateParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -8162,6 +8178,35 @@ ], "type": "object" }, + "PaneLinkRegion": { + "description": "Inclusive display-cell columns on a pane's current viewport.", + "properties": { + "end_col": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "row": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "start_col": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "row", + "start_col", + "end_col" + ], + "type": "object" + }, "PaneMoveReason": { "enum": [ "same_tab", @@ -10374,6 +10419,25 @@ ], "type": "object" }, + { + "properties": { + "regions": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneLinkRegion" + }, + "type": "array" + }, + "type": { + "const": "pane_link_resolved", + "type": "string" + } + }, + "required": [ + "type", + "regions" + ], + "type": "object" + }, { "properties": { "handled": { diff --git a/docs/next/website/src/content/docs/agent-automation.mdx b/docs/next/website/src/content/docs/agent-automation.mdx index b109740a8b..e7c02ae312 100644 --- a/docs/next/website/src/content/docs/agent-automation.mdx +++ b/docs/next/website/src/content/docs/agent-automation.mdx @@ -41,7 +41,7 @@ Agent commands accept either a unique live name or the pane ID that currently ho An available shell pane is at its interactive shell prompt: the shell itself owns the foreground, with no foreground command, editor, or agent running. Return the pane to its prompt before calling `agent start`. -`--kind` selects a supported agent and its canonical executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `maki`, and `muse`. Arguments after `--` are passed unchanged to that executable. +`--kind` selects a supported agent and its canonical executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `letta`, `maki`, and `muse`. Arguments after `--` are passed unchanged to that executable. Successful `agent start` returns only after Herdr detects the expected agent in the same terminal and marks it ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. Startup waits for 30 seconds by default; `--timeout` must be greater than 3000 and no more than 300000 milliseconds. diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index eecdb22d99..d0612c3f26 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -21,6 +21,7 @@ Automatic detection works out of the box for common coding agents. The table sho | Hermes Agent | screen manifest | session | | Qoder CLI | screen manifest | session | | Qwen Code | screen manifest | session | +| Letta Code | screen manifest | session | | Droid | screen manifest | session | | OpenCode | lifecycle plugin when installed; otherwise screen manifest | state and session | | Kilo Code CLI | lifecycle plugin when installed; otherwise screen manifest | state and session | diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index c25b64e6a2..9d736c3990 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -344,7 +344,7 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] Agent targets are either a unique live agent name or the pane ID that currently hosts the agent. Terminal IDs and bare agent-kind labels are not agent targets. Agents started through `agent start` require a name; manually launched agents remain unnamed and use their pane ID. -`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `maki`, and `muse`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. +`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `letta`, `maki`, and `muse`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. A successful start returns only after the expected agent owns the same terminal and is ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. @@ -380,6 +380,9 @@ terminal, or agent target. It prints newline-delimited JSON `terminal.frame` records with base64-encoded ANSI bytes, then a `terminal.closed` record when the server closes the stream. Multiple observers can watch the same terminal without taking input, resize, scroll, or takeover authority. +On Linux and macOS, an observer is disconnected if a socket write makes no progress +for 30 seconds. A stalled stream can end without a final `terminal.closed` record; +the pane and other clients keep running. Quiet panes do not trigger this timeout. `terminal title clear` hands the outer terminal window title back to `ui.window_title`. ## Output waits @@ -414,6 +417,7 @@ herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install mastracode herdr integration install grok @@ -430,6 +434,7 @@ herdr integration uninstall kilo herdr integration uninstall hermes herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall mastracode herdr integration uninstall grok diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 21fa7819bc..d3ead84dc8 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -517,7 +517,9 @@ Search the [Config reference](/docs/config-reference/) for scrollback limits, ne ## Kitty graphics -Herdr renders pane images by default in compatible outer terminals. To disable graphics rendering and the pane graphics API: +Herdr renders pane images by default in compatible outer terminals. Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. + +To disable graphics rendering and the pane graphics API: ```toml [terminal] diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index a849727079..6c9e7a554e 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -73,10 +73,12 @@ Removing or disabling the machine you are viewing returns you to Local. If Local ## Connection problems -- **Reconnecting:** Herdr retries with bounded backoff after a network interruption, sleep, or SSH failure. SSH connections are checked for application-level activity and probed when quiet, so a broken connection does not stay Online indefinitely. Local detects native connection closure or failure instead of using remote health probes. +- **Reconnecting:** Herdr retries automatically after a network interruption, sleep, or SSH failure. Repeated failures increase the delay up to two minutes; brief successful connections do not reset it. A connection must remain healthy for a minute before the next interruption gets a fast retry. SSH connections are checked for application-level activity and probed when quiet, so a broken connection does not stay Online indefinitely. Local detects native connection closure or failure instead of using remote health probes. - **Attention:** The target needs an action that cannot be completed in the background, such as host-key approval, authentication, or a compatible server. Other machines remain usable. - **Saved-machine file error:** An unreadable or invalid catalog leaves current connections unchanged. Herdr shows a notice and automatically retries reading it. +When both installations support bridge idle cleanup, saved-machine connections to Linux and macOS also close their remote bridge after a minute without traffic in either direction. Sleep counts toward that deadline, which is checked when the host wakes. Quiet healthy connections exchange health checks; watching continuous output does not require typing. Cleanup leaves the remote Herdr server, sessions, and pane processes running. Older installations remain compatible without this optional cleanup. + Background connections never answer prompts or install, update, restart, or hand off a server. For Attention, run the standalone setup command shown by Herdr in an interactive terminal, for example: ```bash diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index e8a5a7f3bc..a3ddc96820 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -1,6 +1,6 @@ --- title: Integrations -description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Qwen Code, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. --- Herdr detects supported agents automatically. Install official integrations when you want native agent session restore, direct lifecycle reports, or both. See [Agents](/docs/agents/) for the full status authority model. @@ -23,6 +23,7 @@ herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install mastracode herdr integration install antigravity-cli @@ -45,6 +46,7 @@ herdr integration uninstall kilo herdr integration uninstall hermes herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall mastracode herdr integration uninstall antigravity-cli @@ -58,7 +60,7 @@ Herdr uses integrations in two ways: | Integration type | Agents | Effect | | --- | --- | --- | | Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, MastraCode | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. | -| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | +| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | Custom integrations can also report state that is not visible in the native terminal UI. They do not need to be built into Herdr or use a recognized agent executable. @@ -89,9 +91,9 @@ Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that n [Prime Agent's built-in Herdr reporter](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts) is a real-world example. It activates only inside Herdr, maps agent events to `working`, `idle`, and `blocked`, preserves report ordering across sessions, and releases authority on exit. -Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. +Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. -Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `2`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Letta Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `2`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. ## Pi @@ -137,6 +139,8 @@ herdr integration install claude The hook reports Claude Code session identity to the local Herdr socket on session start. Claude Code state comes from Herdr's screen manifest detection. +Claude integration v10 matches only the documented `SessionStart` sources: `startup`, `resume`, `clear`, `compact`, and `fork`. This prevents Grok's `new` and `load` events from launching the imported Herdr Claude hook, without disabling unrelated Claude-compatible hooks. Reinstall the Claude integration after upgrading to migrate the previous wildcard matcher. New Claude source values require an integration update. + Herdr uses `~/.claude` by default, or `CLAUDE_CONFIG_DIR` when set. The Claude config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. Uninstall removes the matching hook entries and deletes the hook script. ## Codex @@ -277,6 +281,20 @@ Herdr uses `~/.qwen` by default, or `QWEN_HOME` when set. The Qwen config direct Herdr resumes stored Qwen Code sessions with `qwen --resume `. +## Letta Code + +Install the Letta Code hook: + +```bash +herdr integration install letta +``` + +The quiet `SessionStart` hook reports the conversation ID for native restore. Letta Code state still comes from Herdr's screen manifest detection. + +Herdr uses `~/.letta`. The config directory must already exist. Install writes `hooks/herdr-agent-session.sh` (`hooks/herdr-agent-session.ps1` on Windows) and adds a Herdr entry to `settings.json`. Uninstall removes only the matching entry and managed script. + +Herdr resumes named conversations with `letta --conversation `. It records the default conversation as `default:` and resumes it with `letta --conversation default --agent `. + ## Cursor Agent CLI Install the Cursor Agent CLI hook: diff --git a/docs/next/website/src/content/docs/ja/agent-automation.mdx b/docs/next/website/src/content/docs/ja/agent-automation.mdx index 73a8e31fb8..f69d6a3a75 100644 --- a/docs/next/website/src/content/docs/ja/agent-automation.mdx +++ b/docs/next/website/src/content/docs/ja/agent-automation.mdx @@ -41,7 +41,7 @@ review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') 利用可能なシェルペインとは、対話シェルのプロンプトに戻っており、フォアグラウンドをシェル自身が所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていないペインです。`agent start` の前にプロンプトへ戻してください。 -`--kind` は対応済みエージェントとその標準実行ファイルを選びます。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki`、`muse` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 +`--kind` は対応済みエージェントとその標準実行ファイルを選びます。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki`、`muse` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 成功した `agent start` は、同じターミナルで期待したエージェントを検出し、対話入力の準備ができたと Herdr が判断してから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。起動待機はデフォルトで 30 秒です。`--timeout` は 3000 より大きく 300000 以下のミリ秒で指定します。 diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index 40d6940e51..3a36522121 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -21,6 +21,7 @@ Herdr は複数のコーディングエージェントを同時に動かすた | Hermes Agent | スクリーンマニフェスト | セッション | | Qoder CLI | スクリーンマニフェスト | セッション | | Qwen Code | スクリーンマニフェスト | セッション | +| Letta Code | スクリーンマニフェスト | セッション | | Droid | スクリーンマニフェスト | セッション | | OpenCode | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | | Kilo Code CLI | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 259fc05f38..58cb3e9f21 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -324,7 +324,7 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] エージェントターゲットは、一意なライブエージェント名、または現在そのエージェントをホストしているペイン ID です。ターミナル ID とエージェント kind のラベルだけでは指定できません。`agent start` で起動するエージェントには名前が必須で、手動で起動したエージェントは名前なしのままペイン ID で指定します。 -`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki`、`muse` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 +`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki`、`muse` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 成功した start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 @@ -382,6 +382,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration uninstall pi herdr integration uninstall omp @@ -397,6 +398,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration status [--outdated-only] ``` diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx index 0ff999335c..69bda8c190 100644 --- a/docs/next/website/src/content/docs/ja/integrations.mdx +++ b/docs/next/website/src/content/docs/ja/integrations.mdx @@ -1,6 +1,6 @@ --- title: インテグレーション -description: Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Cursor Agent CLI、MastraCode、Antigravity CLI、Grok CLI 向けの Herdr インテグレーションをインストールします。 +description: Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI、Grok CLI 向けの Herdr インテグレーションをインストールします。 --- Herdr は対応エージェントを自動的に検出します。公式インテグレーションは、復元のためのネイティブセッション識別、ライフサイクル状態の報告、またはその両方を追加できます。 @@ -26,6 +26,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install antigravity-cli herdr integration install grok @@ -48,6 +49,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall antigravity-cli herdr integration uninstall grok @@ -60,7 +62,7 @@ Herdr はインテグレーションを 2 つの異なる方法で使います: | インテグレーションの種類 | エージェント | 効果 | | --- | --- | --- | | ライフサイクル権威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | インストールされ、そのペインについて能動的に報告している間は、フックまたはプラグインのイベントが `idle`、`working`、`blocked` を決定します。同じライフサイクル権威に対して、Herdr はスクリーンマニフェストのフォールバックを併用しません。 | -| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 | +| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 | カスタムインテグレーションも、ネイティブのターミナル UI では見えない状態を定義する場合に状態を報告できます。Herdr への組み込みや、認識済みのエージェント実行ファイルは必要ありません。 @@ -91,9 +93,9 @@ Herdr の外では何もしないように、`HERDR_ENV=1` で必要な変数が [Prime Agent の組み込み Herdr レポーター](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)は実際の実装例です。Herdr 内でのみ有効になり、エージェントイベントを `working`、`idle`、`blocked` に対応付け、セッションをまたいで報告順序を維持し、終了時に権威を解放します。 -一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 +一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 -エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `2`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 +エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Letta Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `2`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 ## Pi @@ -139,6 +141,8 @@ herdr integration install claude このフックは、セッション開始時に Claude Code のセッション識別をローカルの Herdr ソケットに報告します。Claude Code の状態は Herdr のスクリーンマニフェスト検出から得られます。 +Claude 統合 v10 は、文書化された `SessionStart` の source 値 `startup`、`resume`、`clear`、`compact`、`fork` のみに一致します。これにより、ほかの Claude 互換フックを無効にせずに、Grok の `new` と `load` イベントがインポートされた Herdr の Claude フックを起動するのを防ぎます。アップグレード後に Claude 統合を再インストールして、従来のワイルドカードマッチャーを移行してください。Claude に新しい source 値が追加された場合は、統合の更新が必要です。 + Herdr はデフォルトで `~/.claude` を使い、`CLAUDE_CONFIG_DIR` が設定されていればそちらを使います。Claude の設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-state.sh` を書き込み、`settings.json` に Herdr のフックエントリを追加します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 ## Codex @@ -279,6 +283,20 @@ Herdr はデフォルトで `~/.qwen` を使い、`QWEN_HOME` が設定されて Herdr は保存された Qwen Code セッションを `qwen --resume ` で resume します。 +## Letta Code + +Letta Code フックをインストールします: + +```bash +herdr integration install letta +``` + +静かな `SessionStart` フックは、ネイティブ復元用の会話 ID を報告します。Letta Code の状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr は `~/.letta` を使います。設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-session.sh`(Windows では `hooks/herdr-agent-session.ps1`)を書き込み、`settings.json` に Herdr エントリを追加します。アンインストールは一致するエントリと管理対象スクリプトだけを削除します。 + +Herdr は名前付き会話を `letta --conversation ` で resume します。デフォルト会話は `default:` として記録し、`letta --conversation default --agent ` で resume します。 + ## Cursor Agent CLI Cursor Agent CLI フックをインストールします: diff --git a/docs/next/website/src/content/docs/ja/session-state.mdx b/docs/next/website/src/content/docs/ja/session-state.mdx index eaf78da42c..ed37071545 100644 --- a/docs/next/website/src/content/docs/ja/session-state.mdx +++ b/docs/next/website/src/content/docs/ja/session-state.mdx @@ -77,6 +77,7 @@ Herdr が resume するのは、現行の公式 Herdr インテグレーショ | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation `、または `default:` の場合は `letta --conversation default --agent ` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index bdfe5177c4..6f3cb695fc 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -67,7 +67,7 @@ Then attach with: herdr --remote workbox ``` -Remote attach and saved SSH machines support Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64, or Windows hosts on x86_64. Local and remote versions do not need to match once both support the stable endpoint generation. On Linux and macOS hosts, Herdr prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. If no compatible binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. Windows hosts must already have a compatible Herdr package with remote host support on `PATH`; remote attach does not install or update it. +Remote attach and saved SSH machines support Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64, or Windows hosts on x86_64. Local and remote versions do not need to match once both support the stable endpoint generation. On Linux and macOS hosts, Herdr prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. On Windows hosts, Herdr checks `PATH` and the active managed release. If no compatible binary exists, interactive runs prompt to install or update one; non-interactive runs fail instead of modifying the host. Linux and macOS installs use `~/.local/bin/herdr`; Windows installs use the complete Windows package and its app-local ConPTY runtime. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings. Existing user keepalive settings win. Linux and macOS clients also use a private per-attach control socket for connection reuse; Windows OpenSSH does not. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket. @@ -88,9 +88,9 @@ herdr --remote workbox --handoff If you SSH into the server first and run `herdr` there, Herdr runs entirely on the server and cannot access your local desktop clipboard beyond normal terminal text paste. -For Linux and macOS hosts, Herdr can copy the current local binary when the platforms match. For Homebrew, mise, and Nix installs, or when the platforms differ, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`. +For Linux and macOS hosts, Herdr can copy the current local binary when the platforms match. For Homebrew, mise, and Nix installs, when the platforms differ, or for Windows hosts, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`. -For local builds or custom binaries targeting Linux or macOS hosts, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach. +For local builds or custom packages, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach. Use a bare Herdr executable for Linux or macOS targets and a complete `herdr-windows-x86_64.zip` package for Windows targets. ```bash HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox diff --git a/docs/next/website/src/content/docs/quick-start.mdx b/docs/next/website/src/content/docs/quick-start.mdx index 8b34de9146..e5c45e4a29 100644 --- a/docs/next/website/src/content/docs/quick-start.mdx +++ b/docs/next/website/src/content/docs/quick-start.mdx @@ -19,7 +19,7 @@ When a session has no workspaces, Herdr opens one automatically. A workspace is Herdr is mouse-native, so start by clicking panes, tabs, workspaces, and agents to focus them. Drag split borders to resize. Right-click for context menus, including splitting panes and creating tabs. Drag-select text to copy it to your clipboard. Double-click a token to select it, or hold the second press and drag to extend the selection by whole words. Both gestures copy when you release the mouse. Copying does not require Ctrl+C. -Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and visible `http://` or `https://` URLs. On macOS, use Ctrl-click for Herdr-handled pane links while mouse capture is enabled; Cmd-click is only available through the terminal-native bypass path, such as Shift-Cmd-click or `ui.mouse_capture = false`. +Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and `http://` or `https://` URLs, including wrapped links whose beginning or end is outside the visible area. Hold Ctrl and move the pointer over a link to underline its visible segments. Hover highlighting clears when you move away, the pane content changes, or Herdr receives a Ctrl-release event; some terminals only report modifier changes with mouse movement. On macOS, use Ctrl-click for Herdr-handled pane links while mouse capture is enabled; Cmd-click is only available through the terminal-native bypass path, such as Shift-Cmd-click or `ui.mouse_capture = false`. If you configure `ui.right_click_passthrough_modifier`, that modifier plus right-click sends right-click, hold, and drag gestures to mouse-reporting pane apps. To make normal right-click go to one pane app, choose **Send right-clicks to pane** from that pane's menu or run `herdr pane input --current --right-click pane` inside it. Right-click the pane frame to reopen Herdr's menu. diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index e47e57a833..51ac8f2b05 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -77,6 +77,7 @@ Native session restore requires these Herdr integration versions or newer: | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation ` or `letta --conversation default --agent ` for `default:` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 2946ff36d0..5bd933ddda 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -461,9 +461,13 @@ Built-in filter fields are `status`, `workspace_id`, `tab_id`, `pane_id`, `agent`, `seen`, and `state_change_seq`. Use `{"token":"name"}` as a field to filter plugin-reported pane metadata. Values are strings, booleans, unsigned numbers, or a context object. Context values are `current_workspace_id` and -`current_tab_id`, and may only be compared to the matching ID field. Effective -status values are `idle`, `working`, `blocked`, `done`, and `unknown`; `done` -means idle and not yet seen. +`current_tab_id`, and may only be compared to the matching ID field. In a +saved-machine client, the selected server's view applies to the combined agent +list. Current workspace and tab context includes that selected machine, so an +identical ID on another machine does not match; independent filter branches, +such as `status` being `blocked`, still match agents on any connected machine. +Effective status values are `idle`, `working`, `blocked`, `done`, and `unknown`; +`done` means idle and not yet seen. Sort fields are `workspace_order`, `tab_order`, `pane_order`, `attention`, `status`, `agent`, `seen`, `state_change_seq`, or `{"token":"name"}`. Sorts are diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx index 8f34dfb9f5..888080c810 100644 --- a/docs/next/website/src/content/docs/windows-beta.mdx +++ b/docs/next/website/src/content/docs/windows-beta.mdx @@ -32,7 +32,7 @@ For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom m | Local persistent sessions | supported | | Native panes through ConPTY | supported | | Windows Terminal / PowerShell app attach | supported | -| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; Windows requires a compatible package with remote host support on `PATH` | +| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; interactive attach and saved-machine setup can install or update Windows packages after confirmation; background reconnect only discovers installed packages | | Remote clipboard images and image-file drops | supported | | `cmd.exe` panes | supported | | Native keyboard and mouse input | supported | @@ -130,7 +130,7 @@ From Windows Terminal, use the same remote command as Linux and macOS: herdr --remote workbox ``` -The target host can run Linux, macOS, or Windows. Windows hosts must already have a compatible Herdr package with remote host support on `PATH`; remote attach does not install or update it. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. +The target host can run Linux, macOS, or Windows. On Windows hosts, remote attach reuses a compatible package from `PATH` or the active managed release. Interactive direct attach and saved-machine setup prompt before installing or updating the complete package when needed. Background saved reconnect only discovers installed packages and cannot prompt for installation or updates. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. Windows updates run through the Windows installer and update the active versioned release path. New terminals and reconnected SSH sessions receive that path; start Herdr there to use the updated client. Compatible running servers keep their panes alive. Restart a server later only when you need server-side changes from the release. Live handoff is Unix-only. diff --git a/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx index 735cdc0ef0..8ad2795229 100644 --- a/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx @@ -41,7 +41,7 @@ review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') 可用的 shell 窗格必须停在交互式 shell 提示符,由 shell 自身占用前台,没有正在前台运行的命令、编辑器或智能体。调用 `agent start` 前先让窗格回到提示符。 -`--kind` 选择受支持的智能体及其标准可执行文件。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki` 和 `muse`。`--` 后的参数会原样传给该可执行文件。 +`--kind` 选择受支持的智能体及其标准可执行文件。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki` 和 `muse`。`--` 后的参数会原样传给该可执行文件。 成功的 `agent start` 只有在 Herdr 于同一终端检测到预期智能体,并确认它可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认等待启动 30 秒;`--timeout` 必须大于 3000 且不超过 300000 毫秒。 diff --git a/docs/next/website/src/content/docs/zh-cn/agents.mdx b/docs/next/website/src/content/docs/zh-cn/agents.mdx index 27b3a8be3d..bc749add6c 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -21,6 +21,7 @@ Herdr 为同时运行多个编程智能体而生。每个智能体都待在一 | Hermes Agent | 屏幕清单 | 会话 | | Qoder CLI | 屏幕清单 | 会话 | | Qwen Code | 屏幕清单 | 会话 | +| Letta Code | 屏幕清单 | 会话 | | Droid | 屏幕清单 | 会话 | | OpenCode | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | | Kilo Code CLI | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index bc01d1f290..8795c45ea6 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -324,7 +324,7 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] 智能体目标只能是唯一的实时智能体名称,或当前承载该智能体的窗格 ID。终端 ID 和单独的智能体 kind 标签不能作为目标。通过 `agent start` 启动的智能体必须有名称;手动启动的智能体保持未命名,通过窗格 ID 寻址。 -`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki` 和 `muse`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 +`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki` 和 `muse`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 成功的 start 只有在预期智能体占用同一终端并可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 @@ -382,6 +382,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration uninstall pi herdr integration uninstall omp @@ -397,6 +398,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration status [--outdated-only] ``` diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 298cbd0bb5..8ca57aef70 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -510,7 +510,9 @@ claude = "on" ## Kitty graphics -Herdr 默认在兼容的外层终端中渲染窗格图像。要禁用图形渲染和窗格图形 API,请设置: +Herdr 默认在兼容的外层终端中渲染窗格图像。图像放置区域与弹窗、菜单或通知重叠时,Herdr 会暂时隐藏整个图像放置区域,而不是只裁剪重叠部分。未被遮挡的图像保持可见,被隐藏的图像放置区域在遮挡消失后恢复显示。图像不会随对话框背景一起变暗。 + +要禁用图形渲染和窗格图形 API,请设置: ```toml [terminal] diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx index aaf84e5446..df1a4d4b62 100644 --- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx @@ -1,6 +1,6 @@ --- title: 集成 -description: 为 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Cursor Agent CLI、MastraCode、Antigravity CLI 和 Grok CLI 安装 Herdr 集成。 +description: 为 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI 和 Grok CLI 安装 Herdr 集成。 --- Herdr 自动检测受支持的智能体。官方集成可以额外提供用于恢复的原生会话身份、生命周期状态上报,或两者兼有。 @@ -26,6 +26,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install antigravity-cli herdr integration install grok @@ -48,6 +49,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall antigravity-cli herdr integration uninstall grok @@ -60,7 +62,7 @@ Herdr 以两种不同方式使用集成: | 集成类型 | 智能体 | 效果 | | --- | --- | --- | | 生命周期权威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | 已安装且在为该窗格主动上报时,由钩子或插件事件决定 `idle`、`working` 和 `blocked`。对同一个生命周期权威,Herdr 不再使用屏幕清单兜底。 | -| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 | +| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 | 自定义集成在定义了原生终端 UI 中不可见的状态时,也可以上报状态。它们不需要内置到 Herdr 中,也不要求 Herdr 识别智能体的可执行文件。 @@ -91,9 +93,9 @@ Herdr 以两种不同方式使用集成: [Prime Agent 内置的 Herdr 上报器](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)是一个真实实现示例。它只在 Herdr 中启用,将智能体事件映射为 `working`、`idle` 和 `blocked`,跨会话保持上报顺序,并在退出时释放权威。 -一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 +一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 -原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `2`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 +原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Letta Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `2`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 ## Pi @@ -139,6 +141,8 @@ herdr integration install claude 该钩子在会话启动时把 Claude Code 的会话身份上报给本地 Herdr socket。Claude Code 的状态来自 Herdr 的屏幕清单检测。 +Claude 集成 v10 仅匹配文档列出的 `SessionStart` 来源: `startup`、`resume`、`clear`、`compact` 和 `fork`。这会阻止 Grok 的 `new` 和 `load` 事件启动导入的 Herdr Claude 钩子,而不会禁用其他 Claude 兼容钩子。升级后请重新安装 Claude 集成,以迁移原有的通配匹配规则。Claude 新增来源值时需要更新集成。 + Herdr 默认使用 `~/.claude`,设置了 `CLAUDE_CONFIG_DIR` 时使用后者。Claude 配置目录必须已经存在。安装会写入 `hooks/herdr-agent-state.sh`,并向 `settings.json` 添加 Herdr 钩子条目。卸载会移除匹配的钩子条目并删除钩子脚本。 ## Codex @@ -279,6 +283,20 @@ Herdr 默认使用 `~/.qwen`,设置了 `QWEN_HOME` 时使用后者。Qwen 配置 Herdr 用 `qwen --resume ` 恢复保存的 Qwen Code 会话。 +## Letta Code + +安装 Letta Code 钩子: + +```bash +herdr integration install letta +``` + +静默的 `SessionStart` 钩子会上报会话 ID,用于原生恢复。Letta Code 状态仍来自 Herdr 的屏幕清单检测。 + +Herdr 使用 `~/.letta`。配置目录必须已经存在。安装会写入 `hooks/herdr-agent-session.sh`(Windows 上为 `hooks/herdr-agent-session.ps1`),并向 `settings.json` 添加 Herdr 条目。卸载只移除匹配条目和托管脚本。 + +Herdr 用 `letta --conversation ` 恢复命名会话。它把默认会话记录为 `default:`,并用 `letta --conversation default --agent ` 恢复该会话。 + ## Cursor Agent CLI 安装 Cursor Agent CLI 钩子: diff --git a/docs/next/website/src/content/docs/zh-cn/session-state.mdx b/docs/next/website/src/content/docs/zh-cn/session-state.mdx index c046a3d4cc..b4f955c625 100644 --- a/docs/next/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/next/website/src/content/docs/zh-cn/session-state.mdx @@ -77,6 +77,7 @@ Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引 | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation `,或对 `default:` 使用 `letta --conversation default --agent ` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index e57e652a1b..e297733eb0 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -1407,6 +1407,17 @@ "off" ] }, + { + "key": "ui.sound.agents.letta", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Letta Code agents.", + "values": [ + "default", + "on", + "off" + ] + }, { "key": "ui.sound.agents.maki", "type": "enum", @@ -1511,7 +1522,7 @@ "key": "experimental.cjk_ime_agents", "type": "list of strings", "default": "[]", - "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, qwen-code, maki." + "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, qwen-code, letta, letta-code, maki." }, { "key": "experimental.cjk_ime_cursor_shape", diff --git a/nix/package.nix b/nix/package.nix index 48de7085be..2a506c3a35 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -40,6 +40,7 @@ rustPlatform.buildRustPackage { fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ./..)) ( lib.fileset.unions [ ../assets + ../distribution/install.ps1 ../docs/next/api/herdr-api.schema.json ../src ../vendor/libghostty-vt diff --git a/scripts/docs/preview.integration.test.ts b/scripts/docs/preview.integration.test.ts index 789c5d31bf..b74ac4ec46 100644 --- a/scripts/docs/preview.integration.test.ts +++ b/scripts/docs/preview.integration.test.ts @@ -7,6 +7,11 @@ import { tmpdir } from 'node:os'; const script = resolve(import.meta.dir, 'preview.mjs'); const temporaryDirectories: string[] = []; +// The fixture shells out to node and git many times. A healthy Windows runner +// finishes in seconds, but a degraded one can run an order of magnitude slower, +// so keep a generous ceiling instead of making the suite a load-sensitive flake. +const fixtureTimeoutMs = process.platform === 'win32' ? 120_000 : 30_000; + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), @@ -27,7 +32,7 @@ describe('preview documentation snapshots', () => { 'selected preview\n', ); await expect(read(root, 'docs/preview/website/stale.mdx')).rejects.toThrow(); - }, 30_000); + }, fixtureTimeoutMs); test('rejects snapshot drift', async () => { const root = await fixture(); @@ -37,7 +42,7 @@ describe('preview documentation snapshots', () => { await write(root, 'docs/preview/website/src/content/docs/index.mdx', 'changed\n'); expect(() => runScript(root, ['check'])).toThrow(); - }, 30_000); + }, fixtureTimeoutMs); }); async function fixture() { diff --git a/scripts/docs/versions.integration.test.ts b/scripts/docs/versions.integration.test.ts index a52be1be8e..9731d3c044 100644 --- a/scripts/docs/versions.integration.test.ts +++ b/scripts/docs/versions.integration.test.ts @@ -7,6 +7,11 @@ import { tmpdir } from 'node:os'; const script = resolve(import.meta.dir, 'versions.mjs'); const temporaryDirectories: string[] = []; +// The fixture shells out to node and git many times. A healthy Windows runner +// finishes in seconds, but a degraded one can run an order of magnitude slower, +// so keep a generous ceiling instead of making the suite a load-sensitive flake. +const fixtureTimeoutMs = process.platform === 'win32' ? 120_000 : 30_000; + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), @@ -103,7 +108,7 @@ describe('documentation release publishing', () => { delete archivedManifest.versions[0].commit; await write(root, 'docs/versions/manifest.json', `${JSON.stringify(archivedManifest)}\n`); expect(() => runScript(root, ['check'])).toThrow(); - }, 30_000); + }, fixtureTimeoutMs); }); async function write(root: string, path: string, content: string) { diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 075c396fe6..04e6cf6313 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -225,6 +225,26 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:grok", "grok", AgentSessionRefKind::Id) => { vec!["grok".into(), "--resume".into(), session_ref.value.clone()] } + ("herdr:letta", "letta", AgentSessionRefKind::Id) => { + if let Some(agent_id) = session_ref.value.strip_prefix("default:") { + if agent_id.is_empty() { + return None; + } + vec![ + "letta".into(), + "--conversation".into(), + "default".into(), + "--agent".into(), + agent_id.into(), + ] + } else { + vec![ + "letta".into(), + "--conversation".into(), + session_ref.value.clone(), + ] + } + } _ => return None, }; @@ -262,6 +282,7 @@ pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:cursor", "cursor") | ("herdr:antigravity_cli", "agy") | ("herdr:grok", "grok") + | ("herdr:letta", "letta") ) } @@ -516,6 +537,32 @@ mod tests { .argv, vec!["grok", "--resume", "grok-session"] ); + assert_eq!( + plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("conversation-123").unwrap() + ) + .unwrap() + .argv, + vec!["letta", "--conversation", "conversation-123"] + ); + assert_eq!( + plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("default:agent-123").unwrap() + ) + .unwrap() + .argv, + vec!["letta", "--conversation", "default", "--agent", "agent-123"] + ); + assert!(plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("default:").unwrap() + ) + .is_none()); } #[test] diff --git a/src/agent_view_eval.rs b/src/agent_view_eval.rs new file mode 100644 index 0000000000..5d3fe84490 --- /dev/null +++ b/src/agent_view_eval.rs @@ -0,0 +1,213 @@ +use std::borrow::Cow; +use std::cmp::Ordering; + +use crate::api::schema::{ + AgentViewBuiltinField, AgentViewBuiltinSortField, AgentViewField, AgentViewFilter, + AgentViewSort, AgentViewSortField, AgentViewSortOrder, AgentViewValue, +}; + +pub(crate) struct AgentViewContext { + pub(crate) scope: usize, + pub(crate) workspace_id: Option, + pub(crate) tab_id: Option, +} + +pub(crate) trait AgentViewEntry { + fn scope(&self) -> usize; + fn status(&self) -> &'static str; + fn workspace_id(&self) -> Option>; + fn tab_id(&self) -> Option>; + fn pane_id(&self) -> Option>; + fn agent(&self) -> Option<&str>; + fn seen(&self) -> bool; + fn state_change_seq(&self) -> Option; + fn token(&self, token: &str) -> Option<&str>; + fn workspace_order(&self) -> Option; + fn tab_order(&self) -> Option; + fn pane_order(&self) -> Option; + fn attention(&self) -> u64; +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum EvalValue<'a> { + String(Cow<'a, str>), + ScopedId { scope: usize, value: Cow<'a, str> }, + Bool(bool), + Number(u64), +} + +pub(crate) fn matches_filter( + context: &AgentViewContext, + entry: &E, + filter: &AgentViewFilter, +) -> bool { + match filter { + AgentViewFilter::All { filters } => filters + .iter() + .all(|filter| matches_filter(context, entry, filter)), + AgentViewFilter::Any { filters } => filters + .iter() + .any(|filter| matches_filter(context, entry, filter)), + AgentViewFilter::Not { filter } => !matches_filter(context, entry, filter), + AgentViewFilter::Eq { field, value } => { + field_value(entry, field) == operand_value(context, field, value) + } + AgentViewFilter::In { field, values } => { + let actual = field_value(entry, field); + values + .iter() + .any(|value| actual == operand_value(context, field, value)) + } + AgentViewFilter::Exists { field } => field_value(entry, field).is_some(), + } +} + +pub(crate) fn compare_entries( + left: &E, + right: &E, + sorts: &[AgentViewSort], +) -> Ordering { + for sort in sorts { + let ordering = compare_optional_values( + sort_value(left, &sort.field), + sort_value(right, &sort.field), + sort.order, + ); + if ordering != Ordering::Equal { + return ordering; + } + } + Ordering::Equal +} + +fn field_value<'a, E: AgentViewEntry + ?Sized>( + entry: &'a E, + field: &AgentViewField, +) -> Option> { + match field { + AgentViewField::Builtin(AgentViewBuiltinField::Status) => { + Some(EvalValue::String(Cow::Borrowed(entry.status()))) + } + AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId) => { + entry.workspace_id().map(|value| EvalValue::ScopedId { + scope: entry.scope(), + value, + }) + } + AgentViewField::Builtin(AgentViewBuiltinField::TabId) => { + entry.tab_id().map(|value| EvalValue::ScopedId { + scope: entry.scope(), + value, + }) + } + AgentViewField::Builtin(AgentViewBuiltinField::PaneId) => { + entry.pane_id().map(|value| EvalValue::ScopedId { + scope: entry.scope(), + value, + }) + } + AgentViewField::Builtin(AgentViewBuiltinField::Agent) => entry + .agent() + .map(|value| EvalValue::String(Cow::Borrowed(value))), + AgentViewField::Builtin(AgentViewBuiltinField::Seen) => Some(EvalValue::Bool(entry.seen())), + AgentViewField::Builtin(AgentViewBuiltinField::StateChangeSeq) => { + entry.state_change_seq().map(EvalValue::Number) + } + AgentViewField::Token { token } => entry + .token(token) + .map(|value| EvalValue::String(Cow::Borrowed(value))), + } +} + +fn operand_value<'a>( + context: &'a AgentViewContext, + field: &AgentViewField, + value: &'a AgentViewValue, +) -> Option> { + match value { + AgentViewValue::String(value) if is_id_field(field) => Some(EvalValue::ScopedId { + scope: context.scope, + value: Cow::Borrowed(value), + }), + AgentViewValue::String(value) => Some(EvalValue::String(Cow::Borrowed(value))), + AgentViewValue::Bool(value) => Some(EvalValue::Bool(*value)), + AgentViewValue::Number(value) => Some(EvalValue::Number(*value)), + AgentViewValue::Context { + context: view_context, + } => match view_context { + crate::api::schema::AgentViewContext::CurrentWorkspaceId => context + .workspace_id + .as_deref() + .map(|value| EvalValue::ScopedId { + scope: context.scope, + value: Cow::Borrowed(value), + }), + crate::api::schema::AgentViewContext::CurrentTabId => { + context.tab_id.as_deref().map(|value| EvalValue::ScopedId { + scope: context.scope, + value: Cow::Borrowed(value), + }) + } + }, + } +} + +fn is_id_field(field: &AgentViewField) -> bool { + matches!( + field, + AgentViewField::Builtin( + AgentViewBuiltinField::WorkspaceId + | AgentViewBuiltinField::TabId + | AgentViewBuiltinField::PaneId + ) + ) +} + +fn sort_value<'a, E: AgentViewEntry + ?Sized>( + entry: &'a E, + field: &AgentViewSortField, +) -> Option> { + match field { + AgentViewSortField::Token { token } => entry + .token(token) + .map(|value| EvalValue::String(Cow::Borrowed(value))), + AgentViewSortField::Builtin(field) => match field { + AgentViewBuiltinSortField::WorkspaceOrder => { + entry.workspace_order().map(EvalValue::Number) + } + AgentViewBuiltinSortField::TabOrder => entry.tab_order().map(EvalValue::Number), + AgentViewBuiltinSortField::PaneOrder => entry.pane_order().map(EvalValue::Number), + AgentViewBuiltinSortField::Attention => Some(EvalValue::Number(entry.attention())), + AgentViewBuiltinSortField::Status => { + Some(EvalValue::String(Cow::Borrowed(entry.status()))) + } + AgentViewBuiltinSortField::Agent => entry + .agent() + .map(|value| EvalValue::String(Cow::Borrowed(value))), + AgentViewBuiltinSortField::Seen => Some(EvalValue::Bool(entry.seen())), + AgentViewBuiltinSortField::StateChangeSeq => { + entry.state_change_seq().map(EvalValue::Number) + } + }, + } +} + +fn compare_optional_values( + left: Option>, + right: Option>, + order: AgentViewSortOrder, +) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => { + let ordering = left.cmp(&right); + if matches!(order, AgentViewSortOrder::Desc) { + ordering.reverse() + } else { + ordering + } + } + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} diff --git a/src/api/schema.rs b/src/api/schema.rs index ba9f0c3a71..7c58fb698d 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -185,6 +185,8 @@ pub enum Method { PaneInputSet(PaneInputSetParams), #[serde(rename = "pane.link.activate")] PaneLinkActivate(PaneLinkActivateParams), + #[serde(rename = "pane.link.resolve")] + PaneLinkResolve(PaneLinkActivateParams), #[serde(rename = "pane.rename")] PaneRename(PaneRenameParams), #[serde(rename = "pane.send_text")] diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 517870f771..6cbcaf4341 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -762,3 +762,11 @@ pub struct PaneReadResult { pub revision: u64, pub truncated: bool, } + +/// Inclusive display-cell columns on a pane's current viewport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneLinkRegion { + pub row: u16, + pub start_col: u16, + pub end_col: u16, +} diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index e6d4de4db2..5cc66bdc25 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -274,6 +274,9 @@ pub enum ResponseResult { context: PluginInvocationContext, log: PluginCommandLogInfo, }, + PaneLinkResolved { + regions: Vec, + }, PaneLinkActivated { #[serde(default, skip_serializing_if = "Option::is_none")] url: Option, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index b566e8f608..d51c565d5b 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -201,8 +201,7 @@ fn generated_protocol_schema_artifact_is_current() { ) }); assert_eq!( - expected, - actual, + expected, actual, "generated API schema artifact is stale; run `HERDR_UPDATE_API_SCHEMA=1 just test-one generated_protocol_schema_artifact_is_current`" ); } @@ -1433,3 +1432,27 @@ fn popup_close_request_round_trips() { assert_eq!(json["method"], "popup.close"); assert_eq!(json["params"], serde_json::json!({})); } + +#[test] +fn pane_link_resolve_round_trips() { + let request: Request = serde_json::from_value(serde_json::json!({ + "id": "hover", "method": "pane.link.resolve", + "params": {"pane_id": "pane-1", "viewport_row": 2, "col": 3} + })) + .unwrap(); + assert!(matches!(request.method, Method::PaneLinkResolve(_))); + let result = ResponseResult::PaneLinkResolved { + regions: vec![PaneLinkRegion { + row: 2, + start_col: 3, + end_col: 9, + }], + }; + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["type"], "pane_link_resolved"); + assert_eq!(json["regions"][0]["end_col"], 9); + assert_eq!( + serde_json::from_value::(json).unwrap(), + result + ); +} diff --git a/src/api/server.rs b/src/api/server.rs index b8ef7c3860..ea7597e7cb 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -454,6 +454,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::PaneFocus(_) => "pane.focus", Method::PaneInputSet(_) => "pane.input.set", Method::PaneLinkActivate(_) => "pane.link.activate", + Method::PaneLinkResolve(_) => "pane.link.resolve", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", diff --git a/src/app/actions.rs b/src/app/actions.rs index d5d13d5a6b..85314331ba 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -10,7 +10,6 @@ use crate::events::AppEvent; use crate::layout::PaneId; #[cfg(test)] use crate::layout::{find_in_direction, NavDirection}; -use crate::selection::Selection; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; @@ -1093,65 +1092,13 @@ impl AppState { } } -impl AppState { - pub(crate) fn url_at_pane_surface_cell( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ws_idx: usize, - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - ) -> Option { - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; - let (height, width) = rt.current_size(); - url_at_runtime_cell( - rt, - pane_id, - ratatui::layout::Rect::new(0, 0, width, height), - viewport_row, - col, - rt.scroll_metrics(), - ) - } -} - -fn url_at_runtime_cell( - runtime: &crate::terminal::TerminalRuntime, - pane_id: crate::layout::PaneId, - area: ratatui::layout::Rect, - viewport_row: u16, - col: u16, - metrics: Option, -) -> Option { - if viewport_row >= area.height || col >= area.width { - return None; +pub(super) fn url_from_link_target(target: crate::ghostty::LinkTarget) -> Option { + match target { + crate::ghostty::LinkTarget::Uri(uri) => Some(uri), + crate::ghostty::LinkTarget::Text { text, clicked_byte } => { + url_at_byte(&text, clicked_byte).map(str::to_owned) + } } - let screen_col = area.x.saturating_add(col); - let screen_row = area.y.saturating_add(viewport_row); - if let Some((_, _, uri)) = runtime - .visible_hyperlinks(area) - .into_iter() - .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) - { - return Some(uri); - } - - let visible_selection = Selection::line_range( - pane_id, - crate::selection::absolute_row_for_viewport(0, metrics), - crate::selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics), - area.width.saturating_sub(1), - ); - let visible_text = runtime.extract_selection(&visible_selection)?; - let logical_cell = logical_cell_for_visible_cell(&visible_text, area.width, viewport_row, col)?; - let line_start = visible_text[..logical_cell.byte_index] - .rfind('\n') - .map_or(0, |idx| idx + 1); - let line_end = visible_text[logical_cell.byte_index..] - .find('\n') - .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx); - let line = visible_text.get(line_start..line_end)?; - url_at_column(line, logical_cell.logical_col).map(str::to_owned) } pub(crate) fn safe_web_url(url: &str) -> Option<&str> { @@ -1201,113 +1148,19 @@ pub(crate) fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> { Some(span.columns(&cells)) } -pub(crate) fn url_at_column(row: &str, col: u16) -> Option<&str> { - let cells = text_cells(row); - let clicked_idx = cell_index_at_column(&cells, col)?; - let span = url_spans(&cells) - .into_iter() - .find(|span| span.contains(clicked_idx))?; - let start_byte = byte_index_for_cell(row, span.start); - let end_byte = byte_index_after_cell(row, span.end); - safe_web_url(row.get(start_byte..end_byte)?) +fn url_at_byte(text: &str, clicked_byte: usize) -> Option<&str> { + let range = url_byte_range(text, clicked_byte)?; + text.get(range) } -fn url_spans(cells: &[TextCell]) -> Vec { - let mut spans = Vec::new(); - let mut start = 0; - while start < cells.len() { - if starts_with_chars(&cells[start..], "http://") - || starts_with_chars(&cells[start..], "https://") - { - let mut end = start; - while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() { - end += 1; - } - if let Some(span) = trim_url_edges(cells, CellSpan { start, end }) { - spans.push(span); - } - start = end + 1; - } else { - start += 1; - } - } - spans -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct VisibleTextCell { - pub(crate) byte_index: usize, - pub(crate) ch: char, - pub(crate) logical_col: u16, - pub(crate) screen_row: u16, - pub(crate) screen_col: u16, -} - -pub(crate) fn visible_text_cells(text: &str, pane_width: u16) -> Vec { - if pane_width == 0 { - return Vec::new(); - } - - let mut cells = Vec::new(); - let mut screen_row = 0u16; - let mut screen_col = 0u16; - let mut logical_col = 0u16; - let mut pending_wrap = false; - for (byte_index, ch) in text.char_indices() { - if ch == '\n' { - screen_row = screen_row.saturating_add(1); - screen_col = 0; - logical_col = 0; - pending_wrap = false; - continue; - } - if pending_wrap { - screen_row = screen_row.saturating_add(1); - screen_col = 0; - pending_wrap = false; - } - - let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); - cells.push(VisibleTextCell { - byte_index, - ch, - logical_col, - screen_row, - screen_col, - }); - - logical_col = logical_col.saturating_add(width); - screen_col = screen_col.saturating_add(width); - while screen_col > pane_width { - screen_col -= pane_width; - screen_row = screen_row.saturating_add(1); - } - if width > 0 && screen_col == pane_width { - pending_wrap = true; - screen_col = pane_width.saturating_sub(1); - } - } - cells -} - -pub(crate) fn logical_cell_for_visible_cell( - text: &str, - pane_width: u16, - target_row: u16, - target_col: u16, -) -> Option { - visible_text_cells(text, pane_width) - .into_iter() - .find(|cell| { - let width = u16::from(crate::ghostty::unicode_codepoint_width(cell.ch as u32)); - cell.screen_row == target_row - && if width == 0 { - target_col == cell.screen_col - } else { - target_col >= cell.screen_col - && target_col < cell.screen_col.saturating_add(width) - } - }) +pub(super) fn url_byte_range(text: &str, clicked_byte: usize) -> Option> { + let clicked_idx = text.get(..clicked_byte)?.chars().count(); + let cells = text_cells(text); + let span = url_span_at_column(&cells, clicked_idx)?; + let start = byte_index_for_cell(text, span.start); + let end = byte_index_after_cell(text, span.end); + safe_web_url(text.get(start..end)?)?; + Some(start..end) } fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option { @@ -2314,7 +2167,7 @@ mod tests { } fn selected_url<'a>(row: &'a str, click: &str) -> Option<&'a str> { - url_at_column(row, col_of(row, click)) + url_at_byte(row, row.find(click)?) } fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String { @@ -2506,7 +2359,182 @@ mod tests { } #[test] - fn url_at_column_returns_safe_visible_url_only() { + fn link_resolution_regions_unicode_punctuation_and_explicit_links() { + let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024).unwrap(); + terminal.write("[文档](https://example.com/路径?q=a(b)), next".as_bytes()); + let regions = terminal + .viewport_link_regions(7, 0, url_byte_range) + .unwrap(); + assert_eq!( + regions + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::>(), + vec![(0, 7, 37)] + ); + assert!(terminal + .viewport_link_regions(1, 0, url_byte_range) + .unwrap() + .is_empty()); + terminal.resize(20, 4, 0, 0).unwrap(); + assert_eq!( + terminal + .viewport_link_regions(9, 1, url_byte_range) + .unwrap() + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::>(), + vec![(0, 7, 19), (1, 0, 17)] + ); + for text in [ + "file:///tmp/a", + "javascript:alert(1)", + "\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\", + ] { + let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024).unwrap(); + terminal.write(text.as_bytes()); + assert!(terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .is_empty()); + } + let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024 * 1024).unwrap(); + terminal.write(format!("https://example.com/{}", "a".repeat(9000)).as_bytes()); + assert!(terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .is_empty()); + } + + #[test] + fn link_resolution_regions_keep_grapheme_byte_offsets() { + let mut terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); + terminal.write("e\u{301}(https://example.com/路e\u{301}),".as_bytes()); + let expected = vec![crate::api::schema::PaneLinkRegion { + row: 0, + start_col: 2, + end_col: 24, + }]; + for col in 2..=24 { + assert_eq!( + terminal + .viewport_link_regions(col, 0, url_byte_range) + .unwrap(), + expected, + "column {col}" + ); + } + assert!(terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .is_empty()); + assert!(terminal + .viewport_link_regions(25, 0, url_byte_range) + .unwrap() + .is_empty()); + terminal.resize(21, 1, 0, 0).unwrap(); + let regions = terminal + .viewport_link_regions(1, 0, url_byte_range) + .unwrap(); + assert_eq!( + regions, + vec![crate::api::schema::PaneLinkRegion { + row: 0, + start_col: 0, + end_col: 3 + }] + ); + } + + #[test] + fn link_resolution_regions_clip_wraps_and_wide_padding() { + let mut terminal = crate::ghostty::Terminal::new(21, 3, 1024).unwrap(); + terminal.write("https://example.com/路径".as_bytes()); + let regions = terminal + .viewport_link_regions(1, 1, url_byte_range) + .unwrap(); + assert_eq!( + regions + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::>(), + vec![(0, 0, 19), (1, 0, 3)] + ); + assert!(terminal + .viewport_link_regions(19, 2, url_byte_range) + .unwrap() + .is_empty()); + let mut terminal = crate::ghostty::Terminal::new(20, 2, 1024).unwrap(); + terminal.write(b"https://example.com/abcdefghijklmnopqrstuv"); + assert_eq!( + terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::>(), + vec![(0, 0, 19), (1, 0, 1)] + ); + terminal.scroll_viewport_row(0); + assert_eq!( + terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::>(), + vec![(0, 0, 19), (1, 0, 19)] + ); + assert!(terminal + .viewport_link_regions(0, 2, url_byte_range) + .unwrap() + .is_empty()); + } + + #[test] + fn link_activation_resolves_full_url_with_either_end_offscreen() { + let url = "https://example.com/abcdefghijklmnopqrstuv"; + let mut terminal = crate::ghostty::Terminal::new(20, 2, 1024 * 1024).unwrap(); + terminal.write(url.as_bytes()); + let target = terminal.viewport_link_target(0, 0).unwrap().unwrap(); + assert_eq!(url_from_link_target(target).as_deref(), Some(url)); + terminal.scroll_viewport_row(0); + let target = terminal.viewport_link_target(5, 1).unwrap().unwrap(); + assert_eq!(url_from_link_target(target).as_deref(), Some(url)); + } + + #[test] + fn link_activation_preserves_unicode_and_click_boundaries_after_resize() { + let mut terminal = crate::ghostty::Terminal::new(80, 5, 1024 * 1024).unwrap(); + terminal.write("[文档](https://example.com/路径?q=a(b)), next".as_bytes()); + assert!( + url_from_link_target(terminal.viewport_link_target(1, 0).unwrap().unwrap()).is_none() + ); + assert_eq!( + url_from_link_target(terminal.viewport_link_target(7, 0).unwrap().unwrap()).as_deref(), + Some("https://example.com/路径?q=a(b)") + ); + terminal.resize(20, 5, 0, 0).unwrap(); + assert_eq!( + url_from_link_target(terminal.viewport_link_target(9, 1).unwrap().unwrap()).as_deref(), + Some("https://example.com/路径?q=a(b)") + ); + assert!(terminal.viewport_link_target(19, 4).unwrap().is_none()); + } + + #[test] + fn link_activation_skips_wide_character_wrap_padding() { + let url = "https://example.com/路径"; + let mut terminal = crate::ghostty::Terminal::new(20, 3, 1024).unwrap(); + terminal.write(url.as_bytes()); + for col in 0..4 { + let target = terminal.viewport_link_target(col, 1).unwrap().unwrap(); + assert_eq!(url_from_link_target(target).as_deref(), Some(url)); + } + } + + #[test] + fn url_at_byte_returns_safe_visible_url_only() { assert_eq!( selected_url("see https://example.com/a(b)c.", "example"), Some("https://example.com/a(b)c") diff --git a/src/app/agent_view.rs b/src/app/agent_view.rs index 58c1d7defd..e10d638a38 100644 --- a/src/app/agent_view.rs +++ b/src/app/agent_view.rs @@ -1,9 +1,9 @@ -use std::cmp::Ordering; +use std::borrow::Cow; +use crate::agent_view_eval::{AgentViewContext as EvaluationContext, AgentViewEntry}; use crate::api::schema::{ - AgentStatus, AgentViewBuiltinField, AgentViewBuiltinSortField, AgentViewContext, - AgentViewField, AgentViewFilter, AgentViewSetParams, AgentViewSort, AgentViewSortField, - AgentViewSortOrder, AgentViewValue, + AgentViewBuiltinField, AgentViewContext, AgentViewField, AgentViewFilter, AgentViewSetParams, + AgentViewSortField, AgentViewValue, }; use crate::ui::AgentPanelEntry; @@ -16,13 +16,6 @@ const MAX_SORT_FIELDS: usize = 8; const MAX_SOURCE_CHARS: usize = 120; const MAX_LABEL_CHARS: usize = 32; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -enum EvalValue { - String(String), - Bool(bool), - Number(u64), -} - pub(crate) fn validate_agent_view(spec: &mut AgentViewSetParams) -> Result<(), String> { spec.source = normalize_source(&spec.source)?; spec.label = spec @@ -52,11 +45,24 @@ pub(crate) fn validate_agent_view_source(source: &str) -> Result pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec) { if let Some(spec) = app.agent_view_override.as_ref() { + let context = evaluation_context(app); if let Some(filter) = &spec.filter { - entries.retain(|entry| matches_filter(app, entry, filter)); + entries.retain(|entry| { + crate::agent_view_eval::matches_filter( + &context, + &AppAgentViewEntry { app, entry }, + filter, + ) + }); } if !spec.sort.is_empty() { - entries.sort_by(|left, right| compare_entries(app, left, right, &spec.sort)); + entries.sort_by(|left, right| { + crate::agent_view_eval::compare_entries( + &AppAgentViewEntry { app, entry: left }, + &AppAgentViewEntry { app, entry: right }, + &spec.sort, + ) + }); return; } } @@ -81,6 +87,94 @@ pub(crate) fn presented_workspace_idx(app: &AppState) -> Option { app.active } +fn evaluation_context(app: &AppState) -> EvaluationContext { + let workspace = presented_workspace_idx(app).and_then(|index| app.workspaces.get(index)); + EvaluationContext { + scope: 0, + workspace_id: workspace.map(|workspace| workspace.id.clone()), + tab_id: workspace.and_then(|workspace| { + let number = workspace.public_tab_number(workspace.active_tab)?; + Some(crate::workspace::public_tab_id_for_number( + &workspace.id, + number, + )) + }), + } +} + +struct AppAgentViewEntry<'a> { + app: &'a AppState, + entry: &'a AgentPanelEntry, +} + +impl AgentViewEntry for AppAgentViewEntry<'_> { + fn scope(&self) -> usize { + 0 + } + + fn status(&self) -> &'static str { + status_name(self.entry.state, self.entry.seen) + } + + fn workspace_id(&self) -> Option> { + self.app + .workspaces + .get(self.entry.ws_idx) + .map(|workspace| Cow::Borrowed(workspace.id.as_str())) + } + + fn tab_id(&self) -> Option> { + public_tab_id(self.app, self.entry).map(Cow::Owned) + } + + fn pane_id(&self) -> Option> { + public_pane_id(self.app, self.entry).map(Cow::Owned) + } + + fn agent(&self) -> Option<&str> { + self.entry.agent_kind_label.as_deref() + } + + fn seen(&self) -> bool { + self.entry.seen + } + + fn state_change_seq(&self) -> Option { + self.entry.last_agent_state_change_seq + } + + fn token(&self, token: &str) -> Option<&str> { + self.entry.tokens.get(token).map(String::as_str) + } + + fn workspace_order(&self) -> Option { + Some(self.entry.ws_idx as u64) + } + + fn tab_order(&self) -> Option { + self.app + .workspaces + .get(self.entry.ws_idx) + .and_then(|workspace| workspace.public_tab_number(self.entry.tab_idx)) + .map(|number| number as u64) + } + + fn pane_order(&self) -> Option { + self.app + .workspaces + .get(self.entry.ws_idx) + .and_then(|workspace| workspace.public_pane_number(self.entry.pane_id)) + .map(|number| number as u64) + } + + fn attention(&self) -> u64 { + u64::from(super::api_helpers::tab_attention_priority( + self.entry.state, + self.entry.seen, + )) + } +} + fn normalize_source(source: &str) -> Result { let source = source.trim(); if source.is_empty() @@ -228,180 +322,16 @@ fn validate_token(token: &str) -> Result<(), String> { Ok(()) } -fn matches_filter(app: &AppState, entry: &AgentPanelEntry, filter: &AgentViewFilter) -> bool { - match filter { - AgentViewFilter::All { filters } => filters - .iter() - .all(|filter| matches_filter(app, entry, filter)), - AgentViewFilter::Any { filters } => filters - .iter() - .any(|filter| matches_filter(app, entry, filter)), - AgentViewFilter::Not { filter } => !matches_filter(app, entry, filter), - AgentViewFilter::Eq { field, value } => { - field_value(app, entry, field) == operand_value(app, value) - } - AgentViewFilter::In { field, values } => { - let actual = field_value(app, entry, field); - values - .iter() - .any(|value| actual == operand_value(app, value)) - } - AgentViewFilter::Exists { field } => field_value(app, entry, field).is_some(), - } -} - -fn compare_entries( - app: &AppState, - left: &AgentPanelEntry, - right: &AgentPanelEntry, - sorts: &[AgentViewSort], -) -> Ordering { - for sort in sorts { - let left = sort_value(app, left, &sort.field); - let right = sort_value(app, right, &sort.field); - let ordering = compare_optional_values(left, right, sort.order); - if ordering != Ordering::Equal { - return ordering; - } - } - Ordering::Equal -} - -fn compare_optional_values( - left: Option, - right: Option, - order: AgentViewSortOrder, -) -> Ordering { - match (left, right) { - (Some(left), Some(right)) => { - let ordering = left.cmp(&right); - if matches!(order, AgentViewSortOrder::Desc) { - ordering.reverse() - } else { - ordering - } - } - (Some(_), None) => Ordering::Less, - (None, Some(_)) => Ordering::Greater, - (None, None) => Ordering::Equal, - } -} - -fn field_value( - app: &AppState, - entry: &AgentPanelEntry, - field: &AgentViewField, -) -> Option { - match field { - AgentViewField::Builtin(field) => builtin_field_value(app, entry, *field), - AgentViewField::Token { token } => entry.tokens.get(token).cloned().map(EvalValue::String), - } -} - -fn builtin_field_value( - app: &AppState, - entry: &AgentPanelEntry, - field: AgentViewBuiltinField, -) -> Option { - match field { - AgentViewBuiltinField::Status => { - Some(EvalValue::String(status_name(entry.state, entry.seen))) - } - AgentViewBuiltinField::WorkspaceId => app - .workspaces - .get(entry.ws_idx) - .map(|workspace| EvalValue::String(workspace.id.clone())), - AgentViewBuiltinField::TabId => public_tab_id(app, entry).map(EvalValue::String), - AgentViewBuiltinField::PaneId => public_pane_id(app, entry).map(EvalValue::String), - AgentViewBuiltinField::Agent => entry.agent_kind_label.clone().map(EvalValue::String), - AgentViewBuiltinField::Seen => Some(EvalValue::Bool(entry.seen)), - AgentViewBuiltinField::StateChangeSeq => { - entry.last_agent_state_change_seq.map(EvalValue::Number) - } - } -} - -fn operand_value(app: &AppState, value: &AgentViewValue) -> Option { - match value { - AgentViewValue::String(value) => Some(EvalValue::String(value.clone())), - AgentViewValue::Bool(value) => Some(EvalValue::Bool(*value)), - AgentViewValue::Number(value) => Some(EvalValue::Number(*value)), - AgentViewValue::Context { context } => context_value(app, *context), - } -} - -fn context_value(app: &AppState, context: AgentViewContext) -> Option { - let ws_idx = presented_workspace_idx(app)?; - let workspace = app.workspaces.get(ws_idx)?; - match context { - AgentViewContext::CurrentWorkspaceId => Some(EvalValue::String(workspace.id.clone())), - AgentViewContext::CurrentTabId => { - let tab_number = workspace.public_tab_number(workspace.active_tab)?; - Some(EvalValue::String( - crate::workspace::public_tab_id_for_number(&workspace.id, tab_number), - )) - } - } -} - -fn sort_value( - app: &AppState, - entry: &AgentPanelEntry, - field: &AgentViewSortField, -) -> Option { - match field { - AgentViewSortField::Token { token } => { - entry.tokens.get(token).cloned().map(EvalValue::String) - } - AgentViewSortField::Builtin(field) => match field { - AgentViewBuiltinSortField::WorkspaceOrder => { - Some(EvalValue::Number(entry.ws_idx as u64)) - } - AgentViewBuiltinSortField::TabOrder => app - .workspaces - .get(entry.ws_idx) - .and_then(|workspace| workspace.public_tab_number(entry.tab_idx)) - .map(|number| EvalValue::Number(number as u64)), - AgentViewBuiltinSortField::PaneOrder => app - .workspaces - .get(entry.ws_idx) - .and_then(|workspace| workspace.public_pane_number(entry.pane_id)) - .map(|number| EvalValue::Number(number as u64)), - AgentViewBuiltinSortField::Attention => Some(EvalValue::Number(u64::from( - super::api_helpers::tab_attention_priority(entry.state, entry.seen), - ))), - AgentViewBuiltinSortField::Status => { - Some(EvalValue::String(status_name(entry.state, entry.seen))) - } - AgentViewBuiltinSortField::Agent => { - entry.agent_kind_label.clone().map(EvalValue::String) - } - AgentViewBuiltinSortField::Seen => Some(EvalValue::Bool(entry.seen)), - AgentViewBuiltinSortField::StateChangeSeq => { - entry.last_agent_state_change_seq.map(EvalValue::Number) - } - }, +fn status_name(state: crate::detect::AgentState, seen: bool) -> &'static str { + match (state, seen) { + (crate::detect::AgentState::Idle, false) => "done", + (crate::detect::AgentState::Idle, true) => "idle", + (crate::detect::AgentState::Working, _) => "working", + (crate::detect::AgentState::Blocked, _) => "blocked", + (crate::detect::AgentState::Unknown, _) => "unknown", } } -fn status_name(state: crate::detect::AgentState, seen: bool) -> String { - let status = match (state, seen) { - (crate::detect::AgentState::Idle, false) => AgentStatus::Done, - (crate::detect::AgentState::Idle, true) => AgentStatus::Idle, - (crate::detect::AgentState::Working, _) => AgentStatus::Working, - (crate::detect::AgentState::Blocked, _) => AgentStatus::Blocked, - (crate::detect::AgentState::Unknown, _) => AgentStatus::Unknown, - }; - match status { - AgentStatus::Idle => "idle", - AgentStatus::Working => "working", - AgentStatus::Blocked => "blocked", - AgentStatus::Done => "done", - AgentStatus::Unknown => "unknown", - } - .to_string() -} - fn public_tab_id(app: &AppState, entry: &AgentPanelEntry) -> Option { let workspace = app.workspaces.get(entry.ws_idx)?; let number = workspace.public_tab_number(entry.tab_idx)?; @@ -423,7 +353,9 @@ fn public_pane_id(app: &AppState, entry: &AgentPanelEntry) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::api::schema::{AgentViewBuiltinSortField, AgentViewSortField}; + use crate::api::schema::{ + AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField, AgentViewSortOrder, + }; use crate::detect::{Agent, AgentState}; use crate::workspace::Workspace; diff --git a/src/app/api.rs b/src/app/api.rs index 9bd0248100..1270feff4b 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1011,7 +1011,7 @@ impl App { return self.handle_workspace_create(request.id, params); } Method::WorkspaceFocus(target) => { - return self.handle_workspace_focus(request.id, target) + return self.handle_workspace_focus(request.id, target); } Method::WorkspaceRename(params) => { return self.handle_workspace_rename(request.id, params); @@ -1026,7 +1026,7 @@ impl App { return self.handle_workspace_report_metadata(request.id, params); } Method::WorkspaceClose(target) => { - return self.handle_workspace_close(request.id, target) + return self.handle_workspace_close(request.id, target); } Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params), Method::WorktreeCreate(params) => { @@ -1059,7 +1059,7 @@ impl App { Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), Method::AgentViewSet(params) => return self.handle_agent_view_set(request.id, params), Method::AgentViewClear(params) => { - return self.handle_agent_view_clear(request.id, params) + return self.handle_agent_view_clear(request.id, params); } Method::AgentStart(params) => return self.handle_agent_start(request.id, params), Method::AgentPrompt(_) => { @@ -1079,7 +1079,7 @@ impl App { Method::AgentRead(params) => return self.handle_agent_read(request.id, params), Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target), Method::AgentSendKeys(params) => { - return self.handle_agent_send_keys(request.id, params) + return self.handle_agent_send_keys(request.id, params); } Method::PaneSplit(params) => return self.handle_pane_split(request.id, params), Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params), @@ -1118,6 +1118,9 @@ impl App { Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target), Method::PaneInputSet(params) => return self.handle_pane_input_set(request.id, params), + Method::PaneLinkResolve(params) => { + return self.handle_pane_link_resolve(request.id, params); + } Method::PaneLinkActivate(params) => { return self.handle_pane_link_activate(request.id, params); } @@ -1168,7 +1171,7 @@ impl App { } Method::PaneSendText(params) => return self.handle_pane_send_text(request.id, params), Method::PaneSendInput(params) => { - return self.handle_pane_send_input(request.id, params) + return self.handle_pane_send_input(request.id, params); } Method::PaneClose(target) => return self.handle_pane_close(request.id, target), Method::PopupClose(_) => { @@ -2067,7 +2070,13 @@ mod tests { observed_at: std::time::Instant::now(), }); - assert!(app.state.terminals[&terminal_id].agent_name.is_none()); + // The release event is this test's subject; the name outliving the + // observation is pinned by + // `a_process_exit_observation_alone_does_not_free_the_name`. + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + agent_name + ); assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( &event.data, crate::api::schema::EventData::PaneAgentDetected { @@ -2123,7 +2132,9 @@ mod tests { let terminal = &app.state.terminals[&terminal_id]; assert_eq!(terminal.state, AgentState::Idle); - assert!(terminal.agent_name.is_none()); + // Releasing the registration does not free the name yet; a wrong + // observation must not cost a live agent the handle its owner gave it. + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( event.data, crate::api::schema::EventData::PaneAgentDetected { released: true, .. } diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 1c0a5369b1..9289ae02f4 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -472,6 +472,68 @@ mod tests { assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"\r")); } + #[tokio::test] + async fn a_false_process_exit_makes_a_named_live_agent_unreachable_by_name() { + // Reproduces the registration loss reported on #3225 by rszrszrsz: + // a live agent pane with an assigned name stops resolving by that name + // while its process keeps running, and renaming is the only recovery. + let mut app = app_with_agent(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let observed_at = std::time::Instant::now(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_agent_name("reviewer".into()); + + let found = app.handle_agent_get( + "req:before".into(), + AgentTarget { + target: "reviewer".into(), + }, + ); + assert!( + serde_json::from_str::(&found).is_ok(), + "the assigned name must resolve while the agent is running: {found}" + ); + + // One process-exit observation, then the same agent is observed alive + // again on the next probe - the process never actually went away. + app.handle_internal_event(crate::events::AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at, + }); + app.handle_internal_event(crate::events::AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: observed_at + std::time::Duration::from_secs(1), + }); + + let terminal = &app.state.terminals[&terminal_id]; + assert_eq!( + terminal.detected_agent, + Some(Agent::Pi), + "the agent process is still there" + ); + + let after = app.handle_agent_get( + "req:after".into(), + AgentTarget { + target: "reviewer".into(), + }, + ); + assert!( + serde_json::from_str::(&after).is_ok(), + "a live agent must stay reachable by its assigned name: {after}" + ); + } + #[tokio::test] async fn agent_prompt_sends_text_then_delays_enter() { let mut app = app_with_agent(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 9b9a28cc7b..a025f0b96f 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -2695,6 +2695,35 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn api_pane_send_keys_preserves_super_chord_in_legacy_pane() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); + let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; + assert_eq!( + app.lookup_runtime_sender(0, internal_pane_id) + .unwrap() + .keyboard_protocol(), + crate::input::KeyboardProtocol::Legacy + ); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { + pane_id, + keys: vec!["cmd+c".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!( + rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[99;9u") + ); + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn api_pane_send_keys_accepts_literal_plus() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 8ad083d43f..35dbee2fe0 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -255,22 +255,25 @@ impl App { .map_err(|(_, message)| message) } - pub(super) fn handle_pane_link_activate( - &mut self, - id: String, - params: PaneLinkActivateParams, - ) -> String { + fn read_checked_pane_link( + &self, + id: &str, + params: &PaneLinkActivateParams, + operation: &str, + read: impl FnOnce(&crate::terminal::TerminalRuntime, u16, u16) -> T, + ) -> Result<(crate::layout::PaneId, T), String> { + let error = |code, message: &str| encode_error(id.to_owned(), code, message); let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return encode_error(id, "pane_not_found", "pane not found"); + return Err(error("pane_not_found", "pane not found")); }; if !self.state.pane_visible_on_active_surface(ws_idx, pane_id) { - return encode_error(id, "stale_target", "pane is no longer visible"); + return Err(error("stale_target", "pane is no longer visible")); } let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { - return encode_error(id, "pane_not_found", "pane runtime not found"); + return Err(error("pane_not_found", "pane runtime not found")); }; let current_offset = runtime .scroll_metrics() @@ -279,11 +282,10 @@ impl App { .offset_from_bottom .is_some_and(|expected| current_offset != Some(expected)) { - return encode_error( - id, + return Err(error( "stale_content", - "pane viewport changed before link activation", - ); + &format!("pane viewport changed before link {operation}"), + )); } let content_revision = runtime.content_seq(); if content_revision % 2 != 0 @@ -291,31 +293,53 @@ impl App { .content_revision .is_some_and(|expected| expected != content_revision) { - return encode_error( - id, + return Err(error( "stale_content", - "pane content changed before link activation", - ); + &format!("pane content changed before link {operation}"), + )); } - let url = self.state.url_at_pane_surface_cell( - &self.terminal_runtimes, - ws_idx, - pane_id, - params.viewport_row, - params.col, - ); + let value = read(runtime, params.col, params.viewport_row); if runtime.content_seq() != content_revision || runtime .scroll_metrics() .map(|metrics| metrics.offset_from_bottom as u64) != current_offset { - return encode_error( - id, + return Err(error( "stale_content", - "pane content or viewport changed during link activation", - ); + &format!("pane content or viewport changed during link {operation}"), + )); + } + Ok((pane_id, value)) + } + + pub(super) fn handle_pane_link_resolve( + &mut self, + id: String, + params: PaneLinkActivateParams, + ) -> String { + match self.read_checked_pane_link(&id, ¶ms, "resolution", |runtime, col, row| { + runtime.link_regions_at(col, row, crate::app::actions::url_byte_range) + }) { + Ok((_, regions)) => encode_success(id, ResponseResult::PaneLinkResolved { regions }), + Err(error) => error, } + } + + pub(super) fn handle_pane_link_activate( + &mut self, + id: String, + params: PaneLinkActivateParams, + ) -> String { + let (pane_id, url) = + match self.read_checked_pane_link(&id, ¶ms, "activation", |runtime, col, row| { + runtime + .link_target_at(col, row) + .and_then(crate::app::actions::url_from_link_target) + }) { + Ok(value) => value, + Err(error) => return error, + }; let handled = match url.as_deref() { Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) { Ok(handled) => handled, @@ -789,6 +813,71 @@ mod tests { }; use std::time::{SystemTime, UNIX_EPOCH}; + #[tokio::test] + async fn pane_link_resolve_checks_staleness_without_side_effects() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("hover")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let public_id = app.public_pane_id(0, pane_id).unwrap(); + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 4); + runtime.test_process_pty_bytes(b"https://example.com"); + let revision = runtime.content_seq(); + app.terminal_runtimes.insert(terminal_id, runtime); + let params = PaneLinkActivateParams { + pane_id: public_id, + viewport_row: 0, + col: 1, + content_revision: Some(revision), + offset_from_bottom: Some(0), + }; + let response = app.handle_api_request(Request { + id: "hover".into(), + method: Method::PaneLinkResolve(params.clone()), + }); + assert!( + matches!(response_result(&response), ResponseResult::PaneLinkResolved { regions } if regions.len() == 1) + ); + assert!( + rx.try_recv().is_err(), + "resolution must not write to the PTY" + ); + for stale in [ + PaneLinkActivateParams { + content_revision: Some(revision + 2), + ..params.clone() + }, + PaneLinkActivateParams { + offset_from_bottom: Some(1), + ..params.clone() + }, + ] { + let response = app.handle_pane_link_resolve("hover".into(), stale); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["error"]["code"], "stale_content"); + } + let changed = + app.read_checked_pane_link("hover", ¶ms, "resolution", |runtime, _, _| { + runtime.test_process_pty_bytes(b"changed"); + }); + let response: serde_json::Value = serde_json::from_str(&changed.unwrap_err()).unwrap(); + assert_eq!(response["error"]["code"], "stale_content"); + assert_eq!( + response["error"]["message"], + "pane content or viewport changed during link resolution" + ); + assert!(rx.try_recv().is_err()); + + app.state.active = None; + let response: serde_json::Value = + serde_json::from_str(&app.handle_pane_link_resolve("hover".into(), params)).unwrap(); + assert_eq!(response["error"]["code"], "stale_target"); + } + fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( @@ -1649,6 +1738,112 @@ command = ["cmd.exe", "/d", "/c", "slot.cmd", "default"] } } + #[cfg(target_os = "linux")] + #[tokio::test] + async fn plugin_launch_survives_executable_replacement() { + const CHILD_ROOT: &str = "HERDR_TEST_PLUGIN_REPLACEMENT_ROOT"; + if let Some(root) = std::env::var_os(CHILD_ROOT) { + let root = std::path::PathBuf::from(root); + let executable = std::env::current_exe().unwrap(); + let replacement = root.join("replacement"); + std::fs::copy(&executable, &replacement).unwrap(); + std::fs::rename(&replacement, &executable).unwrap(); + assert!(!std::env::current_exe().unwrap().exists()); + + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-update")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + let plugin_root = root.join("plugin"); + write_manifest_content( + &plugin_root, + r#" +id = "example.update" +name = "Update Probe" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux"] + +[[actions]] +id = "probe" +title = "Probe executable" +command = ["sh", "-c", '"$HERDR_BIN_PATH" --list >/dev/null; printf "%s\n" "$?" > action-status'] + +[[panes]] +id = "probe" +title = "Probe executable" +command = ["sh", "-c", '"$HERDR_BIN_PATH" --list >/dev/null; printf "%s\n" "$?" > pane-status'] +"#, + ); + link_manifest(&mut app, &plugin_root); + app.invoke_plugin_action_from_keybind("example.update.probe".into(), None) + .unwrap(); + let action_status = read_capture_when_ready(&plugin_root.join("action-status"), || { + app.drain_all_internal_events(); + }); + let open = app.handle_api_request(Request { + id: "update-pane".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.update".into(), + entrypoint: "probe".into(), + placement: Some(PluginPanePlacement::Overlay), + width: None, + height: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + assert!(matches!( + response_result(&open), + ResponseResult::PluginPaneOpened { .. } + )); + let pane_status = read_capture_when_ready(&plugin_root.join("pane-status"), || {}); + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + assert_eq!( + (action_status.trim(), pane_status.trim()), + ("0", "0"), + "plugin action and pane must launch Herdr after its executable is replaced" + ); + return; + } + + let root = std::path::PathBuf::from("/var/tmp").join(format!( + "herdr-plugin-update-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let executable = root.join("herdr test"); + std::fs::copy(std::env::current_exe().unwrap(), &executable).unwrap(); + let result = std::process::Command::new(&executable) + .args([ + "--exact", + "app::api::plugins::tests::plugin_launch_survives_executable_replacement", + "--nocapture", + ]) + .env(CHILD_ROOT, &root) + .output(); + std::fs::remove_dir_all(&root).unwrap(); + let output = result.unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index 8553ce91f7..057918861a 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -260,7 +260,7 @@ impl App { entrypoint.to_string(), )); env.push(("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json)); - if let Ok(current_exe) = std::env::current_exe() { + if let Ok(current_exe) = crate::platform::launch_executable() { env.push(( "HERDR_BIN_PATH".to_string(), current_exe.display().to_string(), diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs index b956fc987b..30e2f7ae56 100644 --- a/src/app/api/plugins/runtime.rs +++ b/src/app/api/plugins/runtime.rs @@ -46,7 +46,7 @@ impl App { ("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()), ("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json), ]); - if let Ok(current_exe) = std::env::current_exe() { + if let Ok(current_exe) = crate::platform::launch_executable() { env.push(( "HERDR_BIN_PATH".to_string(), current_exe.display().to_string(), diff --git a/src/app/pane_graphics.rs b/src/app/pane_graphics.rs index 40081cd365..71f998d890 100644 --- a/src/app/pane_graphics.rs +++ b/src/app/pane_graphics.rs @@ -159,12 +159,6 @@ impl Runtime { .sum() } - pub(crate) fn active_for_pane(&self, pane_id: PaneId) -> bool { - self.slots - .iter() - .any(|((id, _), slot)| *id == pane_id && slot.layer.is_some()) - } - pub(crate) fn attach_stream_active( &mut self, key: &Key, diff --git a/src/checksum.rs b/src/checksum.rs index 4f61a33cb2..ed84169b07 100644 --- a/src/checksum.rs +++ b/src/checksum.rs @@ -25,7 +25,7 @@ pub(crate) fn verify_sha256(path: &Path, expected: &str) -> io::Result<()> { Ok(()) } -fn file_sha256(path: &Path) -> io::Result { +pub(crate) fn file_sha256(path: &Path) -> io::Result { let mut file = File::open(path)?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; diff --git a/src/cli/integration.rs b/src/cli/integration.rs index d0a53f7b53..a8d5a5bfd4 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -38,38 +38,63 @@ fn integration_status(args: &[String]) -> std::io::Result { for status in crate::integration::installed_integration_statuses() { let target = crate::integration::integration_target_label(status.target); - let version = match status.installed_version { - Some(version) => format!("v{version}"), - None => "legacy".to_string(), - }; - let state = match status.state { - crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(), - crate::integration::IntegrationStatusKind::Current => { - format!("current ({version})") - } - crate::integration::IntegrationStatusKind::Outdated - if status - .installed_version - .is_some_and(|installed| installed >= status.expected_version) => - { - format!("needs repair ({version})") - } - crate::integration::IntegrationStatusKind::Outdated => { - format!("outdated ({version} < v{})", status.expected_version) - } - }; + let state = describe_integration_state( + status.state, + status.installed_version, + status.expected_version, + ); println!("{target}: {state} ({})", status.path.display()); } + if let Some(status) = crate::integration::experimental_letta_integration_status() { + let state = describe_integration_state( + status.state, + status.installed_version, + status.expected_version, + ); + println!( + "{} (experimental): {state} ({})", + status.label, + status.path.display() + ); + } + Ok(0) } +fn describe_integration_state( + state: crate::integration::IntegrationStatusKind, + installed_version: Option, + expected_version: u32, +) -> String { + let version = match installed_version { + Some(version) => format!("v{version}"), + None => "legacy".to_string(), + }; + match state { + crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(), + crate::integration::IntegrationStatusKind::Current => format!("current ({version})"), + crate::integration::IntegrationStatusKind::Outdated + if installed_version.is_some_and(|installed| installed >= expected_version) => + { + format!("needs repair ({version})") + } + crate::integration::IntegrationStatusKind::Outdated => { + format!("outdated ({version} < v{expected_version})") + } + } +} + fn integration_install(args: &[String]) -> std::io::Result { let Some(target) = parse_integration_target(args, "install")? else { return Ok(2); }; - match crate::integration::install_target(target) { + let installed = match target { + IntegrationCommandTarget::Builtin(target) => crate::integration::install_target(target), + IntegrationCommandTarget::Letta => crate::integration::install_experimental_letta(), + }; + match installed { Ok(messages) => { print_integration_messages(messages); Ok(0) @@ -86,7 +111,11 @@ fn integration_uninstall(args: &[String]) -> std::io::Result { return Ok(2); }; - match crate::integration::uninstall_target(target) { + let removed = match target { + IntegrationCommandTarget::Builtin(target) => crate::integration::uninstall_target(target), + IntegrationCommandTarget::Letta => crate::integration::uninstall_experimental_letta(), + }; + match removed { Ok(messages) => { print_integration_messages(messages); Ok(0) @@ -104,45 +133,56 @@ fn print_integration_messages(messages: Vec) { } } +/// Integration target accepted by the CLI. Letta is deliberately kept out of +/// the frozen client endpoint `IntegrationTarget` enum and is handled as an +/// experimental CLI-only target until the agent registry replaces it. +enum IntegrationCommandTarget { + Builtin(IntegrationTarget), + Letta, +} + fn parse_integration_target( args: &[String], action: &str, -) -> std::io::Result> { +) -> std::io::Result> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} " + "usage: herdr integration {action} " ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} " + "usage: herdr integration {action} " ); return Ok(None); } let parsed = match target { - "pi" => IntegrationTarget::Pi, - "omp" => IntegrationTarget::Omp, - "claude" => IntegrationTarget::Claude, - "codex" => IntegrationTarget::Codex, - "copilot" => IntegrationTarget::Copilot, - "devin" => IntegrationTarget::Devin, - "droid" => IntegrationTarget::Droid, - "kimi" => IntegrationTarget::Kimi, - "opencode" => IntegrationTarget::Opencode, - "kilo" => IntegrationTarget::Kilo, - "hermes" => IntegrationTarget::Hermes, - "qodercli" => IntegrationTarget::Qodercli, - "qwen" => IntegrationTarget::Qwen, - "cursor" => IntegrationTarget::Cursor, - "mastracode" => IntegrationTarget::Mastracode, - "antigravity-cli" | "antigravity_cli" => IntegrationTarget::AntigravityCli, - "grok" => IntegrationTarget::Grok, + "pi" => IntegrationCommandTarget::Builtin(IntegrationTarget::Pi), + "omp" => IntegrationCommandTarget::Builtin(IntegrationTarget::Omp), + "claude" => IntegrationCommandTarget::Builtin(IntegrationTarget::Claude), + "codex" => IntegrationCommandTarget::Builtin(IntegrationTarget::Codex), + "copilot" => IntegrationCommandTarget::Builtin(IntegrationTarget::Copilot), + "devin" => IntegrationCommandTarget::Builtin(IntegrationTarget::Devin), + "droid" => IntegrationCommandTarget::Builtin(IntegrationTarget::Droid), + "kimi" => IntegrationCommandTarget::Builtin(IntegrationTarget::Kimi), + "opencode" => IntegrationCommandTarget::Builtin(IntegrationTarget::Opencode), + "kilo" => IntegrationCommandTarget::Builtin(IntegrationTarget::Kilo), + "hermes" => IntegrationCommandTarget::Builtin(IntegrationTarget::Hermes), + "qodercli" => IntegrationCommandTarget::Builtin(IntegrationTarget::Qodercli), + "qwen" => IntegrationCommandTarget::Builtin(IntegrationTarget::Qwen), + "letta" => IntegrationCommandTarget::Letta, + "cursor" => IntegrationCommandTarget::Builtin(IntegrationTarget::Cursor), + "mastracode" => IntegrationCommandTarget::Builtin(IntegrationTarget::Mastracode), + "antigravity-cli" | "antigravity_cli" => { + IntegrationCommandTarget::Builtin(IntegrationTarget::AntigravityCli) + } + "grok" => IntegrationCommandTarget::Builtin(IntegrationTarget::Grok), _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, qwen, cursor, mastracode, antigravity-cli, grok" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, qwen, letta, cursor, mastracode, antigravity-cli, grok" ); return Ok(None); } @@ -166,6 +206,7 @@ fn print_integration_help() { eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); eprintln!(" herdr integration install qwen"); + eprintln!(" herdr integration install letta"); eprintln!(" herdr integration install cursor"); eprintln!(" herdr integration install mastracode"); eprintln!(" herdr integration install antigravity-cli"); @@ -183,6 +224,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); eprintln!(" herdr integration uninstall qwen"); + eprintln!(" herdr integration uninstall letta"); eprintln!(" herdr integration uninstall cursor"); eprintln!(" herdr integration uninstall mastracode"); eprintln!(" herdr integration uninstall antigravity-cli"); diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 0d8a58ea05..9465dcc392 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -895,10 +895,12 @@ fn integration_target_arg() -> Arg { } fn integration_target_values() -> Vec<&'static str> { - crate::api::schema::IntegrationTarget::ALL + let mut values: Vec<&'static str> = crate::api::schema::IntegrationTarget::ALL .into_iter() .map(crate::integration::integration_target_label) - .collect() + .collect(); + values.extend_from_slice(crate::integration::EXPERIMENTAL_INTEGRATION_TARGET_LABELS); + values } fn id_command(name: &'static str, id: &'static str, about: &'static str) -> Command { @@ -1121,6 +1123,15 @@ mod tests { fn spec_matches_all_integration_targets() { let cmd = super::command(); let install = command_path(&cmd, &["integration", "install"]); + let mut expected: Vec = crate::api::schema::IntegrationTarget::ALL + .map(crate::integration::integration_target_label) + .map(str::to_string) + .to_vec(); + expected.extend( + crate::integration::EXPERIMENTAL_INTEGRATION_TARGET_LABELS + .iter() + .map(|label| (*label).to_string()), + ); assert_eq!( argument(install, "target") .get_value_parser() @@ -1128,9 +1139,7 @@ mod tests { .unwrap() .map(|value| value.get_name().to_string()) .collect::>(), - crate::api::schema::IntegrationTarget::ALL - .map(crate::integration::integration_target_label) - .map(str::to_string) + expected ); } diff --git a/src/cli/status.rs b/src/cli/status.rs index fa008bdca5..d9c0be79fe 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -252,6 +252,8 @@ struct ClientStatusJson { protocol: u32, endpoint_protocol_generation: u32, endpoint_capabilities: Vec<&'static str>, + remote_host_bridge: bool, + remote_bridge_idle_timeout: bool, binary: String, session: Option, } @@ -297,6 +299,8 @@ fn client_status_json() -> ClientStatusJson { crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY, ], + remote_host_bridge: true, + remote_bridge_idle_timeout: crate::platform::REMOTE_BRIDGE_IDLE_TIMEOUT_SUPPORTED, binary: current_exe_label(), session: crate::session::active_name(), } diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs index 7cf1159f57..6eef8dc8e3 100644 --- a/src/client/endpoint/control.rs +++ b/src/client/endpoint/control.rs @@ -1,7 +1,14 @@ use super::ClientEndpointId; +pub(crate) struct DecodedAgentViewProjection { + pub(crate) boot_id: String, + pub(crate) revision: u64, + pub(crate) view: Result, ()>, +} + pub(crate) enum EndpointControlMessage { HealthPong, + AgentViewProjection(DecodedAgentViewProjection), Snapshot(Box), Ignored, } @@ -13,6 +20,33 @@ pub(crate) fn decode_endpoint_control( if kind == crate::protocol::endpoint::HEALTH_PONG_KIND { return Ok(EndpointControlMessage::HealthPong); } + if kind == crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND { + let Ok(projection): Result = + serde_json::from_str(data) + else { + return Ok(EndpointControlMessage::Ignored); + }; + let view = match projection.view { + Some(value) => serde_json::from_value(value) + .map(Some) + .map_err(|_| ()) + .and_then(|mut view| { + view.as_mut() + .map(crate::app::agent_view::validate_agent_view) + .transpose() + .map(|_| view) + .map_err(|_| ()) + }), + None => Ok(None), + }; + return Ok(EndpointControlMessage::AgentViewProjection( + DecodedAgentViewProjection { + boot_id: projection.boot_id, + revision: projection.revision, + view, + }, + )); + } if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { let snapshot = serde_json::from_str(data) .map_err(|error| format!("invalid endpoint snapshot: {error}"))?; @@ -43,6 +77,63 @@ mod tests { )); } + #[test] + fn agent_view_projection_decodes_and_validates_the_view() { + let view = crate::api::schema::AgentViewSetParams { + source: "example.views".into(), + label: Some("focus".into()), + filter: None, + sort: Vec::new(), + }; + let crate::protocol::ServerMessage::EndpointControl { kind, data } = + crate::protocol::endpoint::agent_view_projection_message("boot", 4, Some(&view)) + .unwrap() + else { + panic!("projection control"); + }; + let EndpointControlMessage::AgentViewProjection(decoded) = + decode_endpoint_control(&kind, &data).unwrap() + else { + panic!("decoded projection"); + }; + assert_eq!(decoded.boot_id, "boot"); + assert_eq!(decoded.revision, 4); + assert_eq!(decoded.view, Ok(Some(view))); + } + + #[test] + fn unsupported_agent_view_payload_falls_back_without_rejecting_endpoint() { + let projection = crate::protocol::endpoint::EndpointAgentViewProjection { + boot_id: "boot".into(), + revision: 5, + view: Some(serde_json::json!({ + "source": "example.views", + "filter": {"op": "future_filter"} + })), + }; + let decoded = decode_endpoint_control( + crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND, + &serde_json::to_string(&projection).unwrap(), + ) + .unwrap(); + let EndpointControlMessage::AgentViewProjection(decoded) = decoded else { + panic!("decoded projection"); + }; + assert_eq!(decoded.view, Err(())); + } + + #[test] + fn malformed_agent_view_envelope_is_ignored() { + assert!(matches!( + decode_endpoint_control( + crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND, + "not json" + ) + .unwrap(), + EndpointControlMessage::Ignored + )); + } + #[test] fn unknown_snapshot_codecs_are_rejected() { assert_eq!( diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs index 458ce92740..a515415677 100644 --- a/src/client/endpoint/supervisor.rs +++ b/src/client/endpoint/supervisor.rs @@ -9,7 +9,9 @@ use crate::protocol::{ClientSurfaceSize, RenderEncoding}; use interprocess::TryClone as _; const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(500); -const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(120); +const MAX_LOCAL_RETRY_DELAY: Duration = Duration::from_secs(30); +const STABLE_CONNECTION_PERIOD: Duration = Duration::from_secs(60); #[derive(Clone, Copy)] pub(crate) struct EndpointConnectOptions { @@ -51,6 +53,7 @@ struct ReconnectState { next_attempt: Option, in_flight: bool, generation: Option, + online_since: Option, } impl ReconnectState { @@ -61,6 +64,7 @@ impl ReconnectState { next_attempt: Some(now), in_flight: false, generation: None, + online_since: None, } } } @@ -200,15 +204,32 @@ impl EndpointSupervisors { state.in_flight = false; match status { ClientEndpointStatus::Online => { - state.attempts = 0; + if endpoint_id.is_local() { + state.attempts = 0; + } + state.online_since.get_or_insert(now); state.next_attempt = None; } ClientEndpointStatus::Attention | ClientEndpointStatus::Disabled => { - state.next_attempt = None + state.online_since = None; + state.next_attempt = None; } ClientEndpointStatus::Connecting | ClientEndpointStatus::Reconnecting => { + // A brief maintenance wake can complete a handshake without restoring the link. + if state.online_since.take().is_some_and(|connected| { + now.saturating_duration_since(connected) >= STABLE_CONNECTION_PERIOD + }) { + state.attempts = 0; + } state.attempts = state.attempts.saturating_add(1); - state.next_attempt = Some(now + retry_delay(state.attempts)); + let delay = retry_delay(state.attempts); + state.next_attempt = Some( + now + if endpoint_id.is_local() { + delay.min(MAX_LOCAL_RETRY_DELAY) + } else { + delay + }, + ); } } true @@ -334,7 +355,7 @@ fn retry_delay(attempt: u32) -> Duration { INITIAL_RETRY_DELAY .saturating_mul( 1_u32 - .checked_shl(attempt.saturating_sub(1).min(6)) + .checked_shl(attempt.saturating_sub(1).min(8)) .unwrap_or(u32::MAX), ) .min(MAX_RETRY_DELAY) @@ -425,6 +446,33 @@ mod tests { assert_eq!(supervisors.endpoints[&other_id].generation, Some(3)); } + #[test] + fn brief_ssh_reconnections_do_not_reset_backoff() { + let now = Instant::now(); + let profile = profile(); + let id = ClientEndpointId::Ssh(profile.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[profile], now); + supervisors.endpoints.get_mut(&id).unwrap().generation = Some(2); + for attempt in 1..=5 { + let connected = now + Duration::from_secs(attempt * 20); + assert!(supervisors.record_status(&id, 2, ClientEndpointStatus::Online, connected)); + let failed = connected + Duration::from_secs(15); + assert!(supervisors.disconnected(&id, 2, failed)); + assert_eq!( + supervisors.endpoints[&id].next_attempt, + Some(failed + INITIAL_RETRY_DELAY * (1 << (attempt - 1))) + ); + } + let connected = now + Duration::from_secs(200); + assert!(supervisors.record_status(&id, 2, ClientEndpointStatus::Online, connected)); + let failed = connected + Duration::from_secs(60); + assert!(supervisors.disconnected(&id, 2, failed)); + assert_eq!( + supervisors.endpoints[&id].next_attempt, + Some(failed + INITIAL_RETRY_DELAY) + ); + } + #[test] fn retry_backoff_is_bounded() { assert_eq!(retry_delay(1), INITIAL_RETRY_DELAY); diff --git a/src/client/input.rs b/src/client/input.rs index 456f59b512..c86f0878e6 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -365,31 +365,11 @@ fn windows_crossterm_reader_loop( Err(_) => break, }; - let raw_sequence_pending = framer.has_pending_input(); - if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { - tracing::debug!( - bytes = ?bytes, - pending_before = raw_sequence_pending, - "windows input routed through raw framer" - ); - if !send_windows_raw_events(framer.push(&bytes), &event_tx) { - return; - } - continue; - } - - if raw_sequence_pending { - tracing::debug!("windows input raw sequence interrupted by semantic event; flushing"); - if !send_windows_raw_events(framer.flush_timeout(), &event_tx) { - return; - } - } - - if windows_event_is_control_key(&event) { - tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); + let (raw_events, event) = frame_windows_crossterm_event(&mut framer, event); + if !send_windows_raw_events(raw_events, &event_tx) { + return; } - - let Some(event) = windows_crossterm_input_event(event) else { + let Some(event) = event else { continue; }; if event_tx @@ -401,8 +381,48 @@ fn windows_crossterm_reader_loop( } if framer.has_pending_input() { - let _ = send_windows_raw_events(framer.flush_timeout(), &event_tx); + let _ = send_windows_raw_events(framer.flush_interrupted(), &event_tx); + } +} + +#[cfg(any(windows, test))] +fn frame_windows_crossterm_event( + framer: &mut crate::raw_input::RawInputFramer, + event: crossterm::event::Event, +) -> ( + Vec, + Option, +) { + let raw_sequence_pending = framer.has_pending_input(); + if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { + tracing::debug!( + bytes = ?bytes, + pending_before = raw_sequence_pending, + "windows input routed through raw framer" + ); + return (framer.push(&bytes), None); + } + + if windows_event_is_control_key(&event) { + tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); + } + let Some(event) = windows_crossterm_input_event(event) else { + // Preserve the existing flush for unrelated pending input, but do not + // cancel mouse recovery for an event that will not be forwarded. + return ( + if raw_sequence_pending { + framer.flush_timeout() + } else { + Vec::new() + }, + None, + ); + }; + if raw_sequence_pending { + tracing::debug!("windows input raw sequence interrupted by semantic event; flushing"); } + // Even a dormant mouse prefix must stop claiming bytes after semantic input. + (framer.flush_interrupted(), Some(event)) } #[cfg(any(windows, test))] @@ -429,7 +449,7 @@ fn windows_crossterm_input_event( } } -#[cfg(windows)] +#[cfg(any(windows, test))] fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool { use crossterm::event::{Event, KeyModifiers}; @@ -773,6 +793,60 @@ mod windows_tests { ); } + #[test] + fn windows_crossterm_semantic_input_cancels_dormant_and_buffered_mouse_recovery() { + for buffered in [false, true] { + let mut framer = crate::raw_input::RawInputFramer::for_host_input(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + if buffered { + assert!(framer.push(b"5").is_empty()); + } + // Printable input bypasses the raw route when no bytes are pending; + // an arrow bypasses it even when a continuation is buffered. + let key = if buffered { + KeyCode::Up + } else { + KeyCode::Char('x') + }; + let event = Event::Key(KeyEvent::new(key, KeyModifiers::empty())); + let (raw, semantic) = frame_windows_crossterm_event(&mut framer, event.clone()); + let raw_bytes: Vec<_> = raw + .into_iter() + .filter_map(|event| { + let crate::raw_input::RawInputEvent::Key(key) = event else { + return None; + }; + key.vt_bytes().map(ToOwned::to_owned) + }) + .collect(); + assert_eq!( + raw_bytes.concat(), + if buffered { b"5".as_slice() } else { b"" } + ); + assert_eq!(semantic, windows_crossterm_input_event(event)); + assert_eq!(framer.push(b"5;28;31M").len(), 8); + } + } + + #[test] + fn windows_crossterm_ignored_event_preserves_mouse_recovery() { + let mut framer = crate::raw_input::RawInputFramer::for_host_input(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"5").is_empty()); + let (raw, semantic) = frame_windows_crossterm_event(&mut framer, Event::Resize(80, 24)); + assert!(raw.is_empty()); + assert!(semantic.is_none()); + let events = framer.push(b";28;31Mx"); + assert!( + matches!(events.as_slice(), [crate::raw_input::RawInputEvent::Key(key)] + if key.code == KeyCode::Char('x')) + ); + assert!(!framer.has_pending_input()); + } + #[test] fn windows_crossterm_printable_press_keeps_key_semantics_and_text() { let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty())); diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 679d5b3198..0a00c14694 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -364,7 +364,7 @@ impl WindowsInputPump { } if let Some(escape) = item.physical_escape_press() { if !self.framer.has_pending_bracketed_paste() { - let raw_events = self.framer.flush_timeout(); + let raw_events = self.framer.flush_interrupted(); events.extend(self.process_raw_events(raw_events)); self.pending_physical_escape = Some((escape, false)); return events; @@ -377,7 +377,7 @@ impl WindowsInputPump { self.process_raw_events(raw_events) } PlatformInputItem::Semantic(event) => { - let raw_events = self.framer.flush_timeout(); + let raw_events = self.framer.flush_interrupted(); let mut events = self.process_raw_events(raw_events); events.push(event); events @@ -436,7 +436,11 @@ impl WindowsInputPump { let raw_events = self.framer.push(&win32_paste_bytes); self.process_raw_events(raw_events) } else { - let raw_events = self.framer.flush_timeout(); + let raw_events = if events.is_empty() { + self.framer.flush_timeout() + } else { + self.framer.flush_interrupted() + }; let mut output = self.process_raw_events(raw_events); output.extend(events); output @@ -3449,6 +3453,91 @@ mod tests { ); } + #[test] + fn vti_mouse_recovery_survives_idle_and_split_continuations() { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + assert!(translator.idle().is_empty()); + for record in "5;28;31M".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + assert!(translator.idle().is_empty()); + } + assert_eq!( + translator.translate(key_char('x')), + translate_with_provenance([key_char('x')]) + ); + } + + #[test] + fn vti_ignored_modifier_record_preserves_mouse_recovery() { + for buffered in [false, true] { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + if buffered { + assert!(translator.translate(key_char('5')).is_empty()); + } + // A modifier-only Win32 input record emits no semantic input. + for record in "\x1b[17;0;0;1;8;3_".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + let tail = if buffered { ";28;31M" } else { "5;28;31M" }; + for record in tail.chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert_eq!( + translator.translate(key_char('x')), + translate_with_provenance([key_char('x')]) + ); + } + } + + #[test] + fn vti_semantic_input_cancels_mouse_recovery_and_preserves_order() { + // These hit Semantic, PasteAwareKey, and physical-Escape forwarding. + for interruption in [ + WindowsInputRecord::Focus(true), + key_vk_with_unicode(0x41, 'a', 0), + key_vk_with_scan_unicode(0x1b, 1, '\x1b', 0), + ] { + for buffered in [false, true] { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + if buffered { + assert!(translator.translate(key_char('5')).is_empty()); + } + let mut events = translator.translate(interruption); + events.extend(translator.idle()); + let mut expected = if buffered { + translate_with_provenance([key_char('5')]) + } else { + Vec::new() + }; + let mut baseline = WindowsInputTranslator::default(); + expected.extend(baseline.translate(interruption)); + expected.extend(baseline.idle()); + assert_eq!(events, expected); + + let mut later = Vec::new(); + for record in "5;28;31M".chars().map(key_char) { + later.extend(translator.translate(record)); + } + assert_eq!( + later, + translate_with_provenance("5;28;31M".chars().map(key_char)) + ); + } + } + } + #[test] fn vti_lone_escape_flushes_only_after_idle() { let mut translator = WindowsInputTranslator::default(); diff --git a/src/client/mod.rs b/src/client/mod.rs index 40edeeb14c..10a089f4df 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -73,6 +73,12 @@ use terminal_setup::{ effective_mouse_capture, effective_sgr_pixel_mouse, set_mouse_capture, setup_direct_attach_terminal, setup_terminal, should_draw_host_cursor, }; + +fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) { + if let Err(err) = set_mouse_capture(enabled, sgr_pixels) { + warn!(err = %err, "failed to re-assert host mouse capture"); + } +} #[cfg(windows)] use terminal_setup::{ enable_windows_virtual_terminal_input, is_ssh_session, windows_vti_input_backend_enabled, @@ -142,6 +148,10 @@ fn run_client_with_mode( init_logging(); let loaded_config = crate::config::Config::load(); + // Windows may not have virtual terminal processing enabled until the rendered + // client initializes the terminal, so defer the host mouse reset to + // `setup_terminal_with_capabilities` instead of emitting raw escapes early. + #[cfg(not(windows))] crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; let client_rendered_shell = attach_request.is_none(); let socket_path = client_socket_path(); @@ -417,6 +427,17 @@ async fn run_client_loop( .as_ref() .and_then(|(_, handshake)| handshake.endpoint_methods.clone()), ); + shell.set_endpoint_agent_view_projection_supported( + &endpoint::ClientEndpointId::Local, + initial + .as_ref() + .and_then(|(_, handshake)| handshake.endpoint_capabilities.as_ref()) + .is_some_and(|capabilities| { + capabilities.iter().any(|capability| { + capability == crate::protocol::endpoint::AGENT_VIEW_PROJECTION_CAPABILITY + }) + }), + ); if local_unavailable { shell.set_endpoint_status( &endpoint::ClientEndpointId::Local, @@ -768,9 +789,16 @@ async fn run_client_loop( continue; } } + let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + if crate::raw_input::events_require_host_mode_refresh(&events) { + refresh_host_mouse_capture( + state.mouse_capture_active, + host_sgr_pixels_active.load(Ordering::Acquire), + ); + } let (outcome, frame) = { let shell = state.shell.as_mut().expect("checked shell mode"); - let outcome = shell.handle_input_bytes(&data); + let outcome = shell.handle_raw_events(events); let frame = outcome .repaint .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) @@ -993,6 +1021,14 @@ async fn run_client_loop( write_stream.active_surface_available(), ); if state.shell.is_some() { + if events.iter().any(|event| { + matches!(event, crate::protocol::ClientInputEvent::FocusGained) + }) { + refresh_host_mouse_capture( + state.mouse_capture_active, + host_sgr_pixels_active.load(Ordering::Acquire), + ); + } let image_target = state .shell .as_ref() @@ -1069,6 +1105,11 @@ async fn run_client_loop( set_mouse_capture(state.mouse_capture_active, false) .map_err(ClientError::ConnectionFailed)?; host_sgr_pixels_active.store(false, Ordering::Release); + } else { + refresh_host_mouse_capture( + state.mouse_capture_active, + host_sgr_pixels_active.load(Ordering::Acquire), + ); } state.reported_size = (new_cols, new_rows); state.reported_cell_size = (cell_width_px, cell_height_px); @@ -1161,8 +1202,15 @@ async fn run_client_loop( ) { continue; } + let agent_view_projection_supported = negotiation.supports_capability( + crate::protocol::endpoint::AGENT_VIEW_PROJECTION_CAPABILITY, + ); let frame = state.shell.as_mut().and_then(|shell| { shell.set_endpoint_methods_for(&endpoint_id, Some(negotiation.methods())); + shell.set_endpoint_agent_view_projection_supported( + &endpoint_id, + agent_view_projection_supported, + ); shell.compose(state.reported_size.0, state.reported_size.1) }); let reader_quit = writer.stop_handle(); @@ -1833,6 +1881,18 @@ async fn run_client_loop( } let snapshot = match endpoint::decode_endpoint_control(&kind, &data) { Ok(endpoint::EndpointControlMessage::HealthPong) => continue, + Ok(endpoint::EndpointControlMessage::AgentViewProjection( + projection, + )) => { + if let Some(shell) = state.shell.as_mut() { + shell.set_endpoint_agent_view_projection_for_generation( + &endpoint_id, + generation, + projection, + ); + } + continue; + } Ok(endpoint::EndpointControlMessage::Ignored) => { debug!(%kind, "ignoring unknown endpoint control message"); continue; @@ -2030,7 +2090,9 @@ async fn run_client_loop( outcome.actions.extend(actions); } let (effects, notification_repaint) = shell.tick_notifications(now); - outcome.repaint |= notification_repaint | shell.tick_copy_feedback(now); + outcome.repaint |= notification_repaint + | shell.tick_copy_feedback(now) + | shell.tick_endpoint_error(now); let frame = outcome .repaint .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) diff --git a/src/client/shell.rs b/src/client/shell.rs index aa1dc5af02..0da206fe90 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -20,6 +20,7 @@ mod global_menu; mod graphics; mod input; mod input_source; +mod link_hover; mod mobile; mod mouse; mod notification_policy; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index be7821618e..cff38a01fc 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -232,9 +232,8 @@ impl ClientShellState { .then(|| candidate.command_id.clone()) }); let Some(command_id) = command_id else { - self.endpoint_error = Some( - "custom command is not available on this endpoint; reload configuration" - .to_owned(), + self.set_endpoint_error( + "custom command is not available on this endpoint; reload configuration", ); outcome.repaint = true; return; @@ -524,6 +523,9 @@ impl ClientShellState { { return (false, Vec::new()); } + if let PendingEndpointKind::PaneLinkResolve { target } = pending.kind { + return self.complete_link_hover(target, result); + } if result.is_ok() { let timeout_key = ClientEndpointNoticeKey { boot_id: boot_id.to_owned(), @@ -569,6 +571,7 @@ impl ClientShellState { } match pending.kind { PendingEndpointKind::Generic => {} + PendingEndpointKind::PaneLinkResolve { .. } => unreachable!("handled above"), PendingEndpointKind::ProductAnnouncementDismiss { version, id } => { return match result { Ok(_) => (false, Vec::new()), @@ -648,8 +651,7 @@ impl ClientShellState { (false, Vec::new()) } Ok(_) => { - self.endpoint_error = - Some("endpoint returned an unexpected selection result".to_owned()); + self.set_endpoint_error("endpoint returned an unexpected selection result"); (true, Vec::new()) } Err(_) => (true, Vec::new()), @@ -707,8 +709,7 @@ impl ClientShellState { (false, replay_action(replay)) } Ok(_) => { - self.endpoint_error = - Some("endpoint returned an unexpected link result".to_owned()); + self.set_endpoint_error("endpoint returned an unexpected link result"); (true, replay_action(replay)) } Err(error) @@ -746,8 +747,9 @@ impl ClientShellState { ), Ok(crate::api::schema::ResponseResult::PaneCopyMotion { .. }) => (false, false), Ok(_) => { - self.endpoint_error = - Some("endpoint returned an unexpected copy-motion result".to_owned()); + self.set_endpoint_error( + "endpoint returned an unexpected copy-motion result", + ); (true, false) } Err(_) => (true, false), @@ -801,8 +803,9 @@ impl ClientShellState { } Ok(_) => { self.cancel_deferred_copy_after_search(generation); - self.endpoint_error = - Some("endpoint returned an unexpected copy-search result".to_owned()); + self.set_endpoint_error( + "endpoint returned an unexpected copy-search result", + ); (true, false) } Err(_) => { @@ -817,8 +820,9 @@ impl ClientShellState { let repaint = match result { Ok(crate::api::schema::ResponseResult::ConfigReload { .. }) => false, Ok(_) => { - self.endpoint_error = - Some("endpoint returned an unexpected config reload result".to_owned()); + self.set_endpoint_error( + "endpoint returned an unexpected config reload result", + ); true } Err(_) => true, diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs index d6fe33529b..e43ab8ccbd 100644 --- a/src/client/shell/agent_sidebar.rs +++ b/src/client/shell/agent_sidebar.rs @@ -241,76 +241,83 @@ pub(super) fn agent_rows( ) -> Vec { ordered_agent_pane_ids(snapshot, config.agent_panel_sort) .into_iter() - .filter_map(|pane_id| { - let agent = snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id)?; - let workspace = snapshot - .workspaces - .iter() - .find(|workspace| workspace.workspace_id == agent.workspace_id)?; - let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); - let pane = snapshot - .panes - .iter() - .find(|pane| pane.pane_id == agent.pane_id); - let tab_count = snapshot - .tabs - .iter() - .filter(|candidate| candidate.workspace_id == agent.workspace_id) - .count(); - let tab_label = tab - .filter(|tab| tab_count > 1 || tab.custom_label) - .map(|tab| tab.label.as_str()); - let agent_label = agent - .display_agent - .as_deref() - .or(agent.name.as_deref()) - .or(agent.agent.as_deref()) - .or(agent.title.as_deref()); - let labels = agent - .state_labels - .iter() - .cloned() - .collect::>(); - let tokens = agent.tokens.iter().cloned().collect::>(); - let state_text = labels - .get(status_text(agent.agent_status)) - .map(String::as_str) - .unwrap_or_else(|| sidebar_status_text(agent.agent_status)); - let canonical_agent = agent - .agent - .as_deref() - .and_then(crate::detect::parse_agent_label); - let rows = crate::ui::sidebar_agent_rows( - &config.agents, - crate::ui::AgentTokenContext { - machine, - workspace: &workspace.label, - tab: tab_label, - pane: agent - .title - .as_deref() - .or_else(|| pane.and_then(|pane| pane.label.as_deref())), - agent_label, - terminal_title: agent.terminal_title.as_deref(), - terminal_title_stripped: agent.terminal_title_stripped.as_deref(), - canonical_agent, - tokens: &tokens, - }, - state_text, - ); - Some(AgentRow { - pane_id: agent.pane_id.clone(), - status: agent.agent_status, - focused: agent.focused, - rows, - }) - }) + .filter_map(|pane_id| agent_row(snapshot, &pane_id, config, machine)) .collect() } +pub(super) fn agent_row( + snapshot: &ClientShellSnapshot, + pane_id: &str, + config: &ClientShellConfig, + machine: Option<&str>, +) -> Option { + let agent = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id)?; + let workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == agent.workspace_id)?; + let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let pane = snapshot + .panes + .iter() + .find(|pane| pane.pane_id == agent.pane_id); + let tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + let tab_label = tab + .filter(|tab| tab_count > 1 || tab.custom_label) + .map(|tab| tab.label.as_str()); + let agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .or(agent.title.as_deref()); + let labels = agent + .state_labels + .iter() + .cloned() + .collect::>(); + let tokens = agent.tokens.iter().cloned().collect::>(); + let state_text = labels + .get(status_text(agent.agent_status)) + .map(String::as_str) + .unwrap_or_else(|| sidebar_status_text(agent.agent_status)); + let canonical_agent = agent + .agent + .as_deref() + .and_then(crate::detect::parse_agent_label); + let rows = crate::ui::sidebar_agent_rows( + &config.agents, + crate::ui::AgentTokenContext { + machine, + workspace: &workspace.label, + tab: tab_label, + pane: agent + .title + .as_deref() + .or_else(|| pane.and_then(|pane| pane.label.as_deref())), + agent_label, + terminal_title: agent.terminal_title.as_deref(), + terminal_title_stripped: agent.terminal_title_stripped.as_deref(), + canonical_agent, + tokens: &tokens, + }, + state_text, + ); + Some(AgentRow { + pane_id: agent.pane_id.clone(), + status: agent.agent_status, + focused: agent.focused, + rows, + }) +} + pub(super) fn render_agent_row( buffer: &mut Buffer, rect: Rect, @@ -332,16 +339,8 @@ pub(super) fn render_agent_row( .fg(palette.subtext0) .add_modifier(Modifier::BOLD) }; - let status_style = Style::default() - .fg(status_color(row.status, palette)) - .add_modifier(if row.focused { - Modifier::empty() - } else { - Modifier::DIM - }); - let secondary = Style::default() - .fg(palette.overlay0) - .add_modifier(Modifier::DIM); + let status_style = Style::default().fg(status_color(row.status, palette)); + let secondary = Style::default().fg(palette.overlay0); let icon = ( status_icon(row.status, config.status_indicators), Style::default().fg(status_color(row.status, palette)), diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index 5c826bf375..c0195f9352 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -5,11 +5,13 @@ use crate::protocol::ClientShellAgent; #[derive(Clone, Copy)] pub(super) struct CachedEndpointSnapshot<'a> { + pub(super) endpoint_index: usize, pub(super) endpoint_id: &'a ClientEndpointId, pub(super) label: &'a str, pub(super) status: ClientEndpointStatus, pub(super) snapshot: &'a ClientShellSnapshot, pub(super) agent_recency: &'a HashMap, + pub(super) agent_presentation: &'a super::endpoint_agent_state::EndpointAgentPresentation, } impl CachedEndpointSnapshot<'_> { @@ -21,18 +23,23 @@ impl CachedEndpointSnapshot<'_> { pub(super) fn cached_endpoint_snapshots( endpoints: &[ClientShellEndpoint], ) -> impl Iterator> { - endpoints.iter().filter_map(|endpoint| { - endpoint - .snapshot - .as_deref() - .map(|snapshot| CachedEndpointSnapshot { - endpoint_id: &endpoint.endpoint_id, - label: &endpoint.label, - status: endpoint.status, - snapshot, - agent_recency: &endpoint.agent_recency, - }) - }) + endpoints + .iter() + .enumerate() + .filter_map(|(endpoint_index, endpoint)| { + endpoint + .snapshot + .as_deref() + .map(|snapshot| CachedEndpointSnapshot { + endpoint_index, + endpoint_id: &endpoint.endpoint_id, + label: &endpoint.label, + status: endpoint.status, + snapshot, + agent_recency: &endpoint.agent_recency, + agent_presentation: &endpoint.agent_presentation, + }) + }) } pub(super) struct AggregateAgentRow<'a> { @@ -46,10 +53,83 @@ pub(super) struct AggregateAgentTarget { pub(super) pane_id: String, } -pub(super) fn aggregate_agent_rows( - endpoints: &[ClientShellEndpoint], +pub(super) fn aggregate_agent_rows<'a>( + endpoints: &'a [ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, sort: crate::config::AgentPanelSortConfig, -) -> Vec> { +) -> Vec> { + let active_index = endpoints + .iter() + .position(|endpoint| &endpoint.endpoint_id == active_endpoint_id); + let active_view = active_index.and_then(|index| { + let endpoint = &endpoints[index]; + if !endpoint.agent_view_projection_supported { + return None; + } + match ClientShellState::endpoint_agent_view(endpoint) { + Some(Ok(view)) => Some(Ok(view.as_ref())), + Some(Err(())) => Some(Err(())), + None if endpoint + .snapshot + .as_deref() + .is_some_and(|snapshot| snapshot.agent_view_label.is_none()) => + { + Some(Ok(None)) + } + None => Some(Err(())), + } + }); + + if let Some(Ok(view)) = active_view { + let mut rows = cached_endpoint_snapshots(endpoints) + .flat_map(|endpoint| { + endpoint + .snapshot + .agents + .iter() + .map(move |agent| AggregateAgentRow { + recency: endpoint + .agent_recency + .get(&agent.pane_id) + .copied() + .unwrap_or_default(), + endpoint, + agent, + }) + }) + .collect::>(); + if let Some(view) = view { + let context = active_index + .and_then(|index| endpoints[index].snapshot.as_deref()) + .map(|snapshot| crate::agent_view_eval::AgentViewContext { + scope: active_index.unwrap_or_default(), + workspace_id: snapshot.focused_workspace_id.clone(), + tab_id: snapshot.focused_tab_id.clone(), + }); + if let (Some(context), Some(filter)) = (context.as_ref(), view.filter.as_ref()) { + rows.retain(|row| { + crate::agent_view_eval::matches_filter( + context, + &ClientAgentViewEntry::new(row), + filter, + ) + }); + } + if !view.sort.is_empty() { + rows.sort_by(|left, right| { + crate::agent_view_eval::compare_entries( + &ClientAgentViewEntry::new(left), + &ClientAgentViewEntry::new(right), + &view.sort, + ) + }); + return rows; + } + } + sort_aggregate_rows(&mut rows, sort); + return rows; + } + let mut rows = cached_endpoint_snapshots(endpoints) .flat_map(|endpoint| { super::agent_sidebar::ordered_agent_pane_ids(endpoint.snapshot, sort) @@ -72,6 +152,14 @@ pub(super) fn aggregate_agent_rows( }) }) .collect::>(); + sort_aggregate_rows(&mut rows, sort); + rows +} + +fn sort_aggregate_rows( + rows: &mut [AggregateAgentRow<'_>], + sort: crate::config::AgentPanelSortConfig, +) { if sort == crate::config::AgentPanelSortConfig::Priority { rows.sort_by_key(|row| { ( @@ -81,14 +169,103 @@ pub(super) fn aggregate_agent_rows( ) }); } - rows +} + +struct ClientAgentViewEntry<'a> { + endpoint_index: usize, + snapshot: &'a ClientShellSnapshot, + agent: &'a ClientShellAgent, + seen: bool, +} + +impl<'a> ClientAgentViewEntry<'a> { + fn new(row: &AggregateAgentRow<'a>) -> Self { + Self { + endpoint_index: row.endpoint.endpoint_index, + snapshot: row.endpoint.snapshot, + agent: row.agent, + seen: row.endpoint.agent_presentation.seen(row.agent), + } + } +} + +impl crate::agent_view_eval::AgentViewEntry for ClientAgentViewEntry<'_> { + fn scope(&self) -> usize { + self.endpoint_index + } + + fn status(&self) -> &'static str { + status_text(self.agent.agent_status) + } + + fn workspace_id(&self) -> Option> { + Some(std::borrow::Cow::Borrowed(&self.agent.workspace_id)) + } + + fn tab_id(&self) -> Option> { + Some(std::borrow::Cow::Borrowed(&self.agent.tab_id)) + } + + fn pane_id(&self) -> Option> { + Some(std::borrow::Cow::Borrowed(&self.agent.pane_id)) + } + + fn agent(&self) -> Option<&str> { + self.agent.agent.as_deref() + } + + fn seen(&self) -> bool { + self.seen + } + + fn state_change_seq(&self) -> Option { + Some(self.agent.state_change_seq) + } + + fn token(&self, token: &str) -> Option<&str> { + self.agent + .tokens + .iter() + .find(|(name, _)| name == token) + .map(|(_, value)| value.as_str()) + } + + fn workspace_order(&self) -> Option { + self.snapshot + .workspaces + .iter() + .position(|workspace| workspace.workspace_id == self.agent.workspace_id) + .map(|index| index as u64) + } + + fn tab_order(&self) -> Option { + self.snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == self.agent.tab_id) + .map(|tab| tab.number as u64) + } + + fn pane_order(&self) -> Option { + let suffix = self + .agent + .pane_id + .strip_prefix(&self.agent.workspace_id)? + .strip_prefix(":p")?; + crate::workspace::decode_public_number(suffix).map(|number| number as u64) + } + + fn attention(&self) -> u64 { + u64::from(status_priority(self.agent.agent_status)) + } } pub(super) fn online_agent_targets( endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, sort: crate::config::AgentPanelSortConfig, ) -> Vec { - aggregate_agent_rows(endpoints, sort) + aggregate_agent_rows(endpoints, active_endpoint_id, sort) .into_iter() .filter(|row| !row.endpoint.stale()) .map(|row| AggregateAgentTarget { diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 261d0549c0..d9dfd00484 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -314,6 +314,7 @@ impl ClientShellState { }); blit_pane_surface(&mut frame, &surface.frame, layout.pane_surface); restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + let mut occlusion = crate::kitty_graphics::surface::Occlusion::default(); let has_selection = self .selection .as_ref() @@ -335,6 +336,7 @@ impl ClientShellState { hit, &self.config.palette, false, + &mut occlusion, ); } let selection_is_stale_copy_projection = !copy_surface_coherent @@ -346,6 +348,13 @@ impl ClientShellState { .is_some_and(|selection| selection.pane_id == hit.pane_id) }); if !selection_is_stale_copy_projection { + if let Some(selection) = + self.selection.as_ref().filter(|s| s.pane_id == hit.pane_id) + { + for rect in selection.visible_rects(hit.inner_rect, hit.scroll) { + occlusion.cover(rect); + } + } crate::ui::render_selection_highlight( self.selection.as_ref(), &mut composed, @@ -366,11 +375,13 @@ impl ClientShellState { hit, &self.config.palette, true, + &mut occlusion, ); } } frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } + self.render_link_hover(&mut frame, &mut occlusion); if self.mode == ClientShellMode::Copy { frame.cursor = None; if let Some(copy_mode) = self.copy_mode.as_ref() { @@ -389,6 +400,7 @@ impl ClientShellState { let mut composed = frame.to_ratatui_buffer()?; let x = hit.inner_rect.x + copy_mode.cursor.col; let y = hit.inner_rect.y + viewport_row as u16; + occlusion.cover(Rect::new(x, y, 1, 1)); composed[(x, y)].set_style( Style::default() .fg(match self.config.palette.panel_bg { @@ -432,17 +444,18 @@ impl ClientShellState { diagnostic_area, diagnostic, &self.config.palette, + |rect| occlusion.cover(rect), ); } let lifecycle_offset = active_lifecycle.as_ref().map_or(0, |(label, status)| { - let _ = endpoint_notices::render_lifecycle_banner( + occlusion.cover(endpoint_notices::render_lifecycle_banner( &mut composed, Rect::new(0, 0, cols, rows), label, *status, u16::from(has_config_diagnostic) + layout.mobile_header.height, &self.config.palette, - ); + )); 1 }); if let Some(notice) = self.visible_endpoint_notice.as_ref() { @@ -483,6 +496,7 @@ impl ClientShellState { ) }; } + occlusion.cover(self.hits.notification_toast); frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } if let Some(feedback) = self.copy_feedback.as_ref() { @@ -501,14 +515,14 @@ impl ClientShellState { self.config.clipboard_toast_position, self.hits.notification_toast, ); - crate::ui::render_copy_feedback_buffer( + occlusion.cover(crate::ui::render_copy_feedback_buffer( &mut composed, feedback_area, feedback, offset, self.config.clipboard_toast_position, &self.config.palette, - ); + )); frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } self.hits.popup = None; @@ -531,6 +545,7 @@ impl ClientShellState { window.y + window.height.saturating_sub(geometry.outer.height) / 2; geometry.inner.x = geometry.outer.x.saturating_add(1); geometry.inner.y = geometry.outer.y.saturating_add(1); + occlusion.start_popup(geometry.outer); let mut composed = frame.to_ratatui_buffer()?; for y in composed.area.y..composed.area.bottom() { for x in composed.area.x..composed.area.right() { @@ -570,6 +585,7 @@ impl ClientShellState { && self.overlay.is_none() { let mut composed = frame.to_ratatui_buffer()?; + occlusion.cover(composed.area); super::mobile::render_mobile_switcher( &mut composed, Rect::new(0, 0, cols, rows), @@ -609,20 +625,27 @@ impl ClientShellState { self.hits.popup = None; } restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + if let Some(bar) = mode_bar { + occlusion.cover(bar); + } if let Some(overlay) = self.overlay.as_ref() { let mut composed = frame.to_ratatui_buffer()?; let cursor = if let ClientShellOverlay::ContextMenu(menu) = overlay { - self.hits.context_menu_rows = + let rendered = render::render_context_menu(&mut composed, menu, &self.config.palette)?; + occlusion.cover(rendered.area); + self.hits.context_menu_rows = rendered.menu_rows; None } else if let ClientShellOverlay::GlobalMenu(menu) = overlay { - self.hits.global_menu_rows = render::render_global_menu( + let rendered = render::render_global_menu( &mut composed, self.hits.global_launcher, menu, snapshot, &self.config.palette, )?; + occlusion.cover(rendered.area); + self.hits.global_menu_rows = rendered.menu_rows; None } else { let rendered = render::render_client_overlay( @@ -634,6 +657,7 @@ impl ClientShellState { &self.config.keybinds, &self.config.palette, )?; + occlusion.cover(rendered.area); self.hits.overlay_primary = rendered.primary; self.hits.overlay_clear = rendered.clear; self.hits.overlay_cancel = rendered.cancel; @@ -680,7 +704,7 @@ impl ClientShellState { self.hits.pane_splits.clear(); self.hits.popup = None; } - self.compose_graphics(&mut frame, layout); + self.compose_graphics(&mut frame, layout, &occlusion); Some(frame) } } @@ -703,6 +727,7 @@ fn render_client_copy_search_highlights( hit: &PaneHit, palette: &Palette, current_only: bool, + occlusion: &mut crate::kitty_graphics::surface::Occlusion, ) { let Some(copy_mode) = copy_mode.filter(|copy_mode| copy_mode.pane_id == hit.pane_id) else { return; @@ -744,7 +769,14 @@ fn render_client_copy_search_highlights( } else { hit.inner_rect.width.saturating_sub(1) }; - for col in start_col..=end_col.min(hit.inner_rect.width.saturating_sub(1)) { + let end_col = end_col.min(hit.inner_rect.width.saturating_sub(1)); + occlusion.cover(Rect::new( + hit.inner_rect.x.saturating_add(start_col), + hit.inner_rect.y.saturating_add(viewport_row), + end_col.saturating_add(1).saturating_sub(start_col), + 1, + )); + for col in start_col..=end_col { buffer[( hit.inner_rect.x.saturating_add(col), hit.inner_rect.y.saturating_add(viewport_row), diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index a11eda604a..879ef842df 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -63,7 +63,7 @@ impl ClientShellState { remote_collapsed_groups, }; if let Err(error) = preferences::store(path, preferences) { - self.endpoint_error = Some(error); + self.set_endpoint_error(error); outcome.repaint = true; } } @@ -97,7 +97,7 @@ impl ClientShellState { .config .apply_snapshot_keybindings(profile.as_deref(), &commands) { - self.endpoint_error = Some(err); + self.set_endpoint_error(err); } } } diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs index fdcd747863..f8ba7a9bd6 100644 --- a/src/client/shell/endpoint_agent_state.rs +++ b/src/client/shell/endpoint_agent_state.rs @@ -71,14 +71,16 @@ impl EndpointAgentPresentation { changed } + pub(super) fn seen(&self, agent: &ClientShellAgent) -> bool { + self.acknowledged + .get(&agent.pane_id) + .is_some_and(|sequence| *sequence >= agent.state_change_seq) + } + fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus { match agent.agent_status { AgentStatus::Idle | AgentStatus::Done => { - if self - .acknowledged - .get(&agent.pane_id) - .is_some_and(|sequence| *sequence >= agent.state_change_seq) - { + if self.seen(agent) { AgentStatus::Idle } else { AgentStatus::Done diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs index a49fccf983..3cc106b36a 100644 --- a/src/client/shell/endpoint_agents.rs +++ b/src/client/shell/endpoint_agents.rs @@ -103,8 +103,17 @@ fn agent_rows( .iter() .filter_map(|endpoint| { endpoint.snapshot.as_deref().map(|snapshot| { - super::agent_sidebar::agent_rows(snapshot, config, Some(&endpoint.label)) - .into_iter() + snapshot + .agents + .iter() + .filter_map(|agent| { + super::agent_sidebar::agent_row( + snapshot, + &agent.pane_id, + config, + Some(&endpoint.label), + ) + }) .map(|agent| ((endpoint.endpoint_id.clone(), agent.pane_id.clone()), agent)) .collect::>() }) @@ -112,18 +121,22 @@ fn agent_rows( .flatten() .collect::>(); - super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort) - .into_iter() - .filter_map(|row| { - let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone()); - let mut agent = rendered_rows.remove(&key)?; - agent.focused &= row.endpoint.endpoint_id == active_endpoint_id; - Some(EndpointAgentRow { - endpoint_id: row.endpoint.endpoint_id.clone(), - machine_label: row.endpoint.label.to_owned(), - stale: row.endpoint.stale(), - agent, - }) + super::aggregate_navigation::aggregate_agent_rows( + endpoints, + active_endpoint_id, + config.agent_panel_sort, + ) + .into_iter() + .filter_map(|row| { + let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone()); + let mut agent = rendered_rows.remove(&key)?; + agent.focused &= row.endpoint.endpoint_id == active_endpoint_id; + Some(EndpointAgentRow { + endpoint_id: row.endpoint.endpoint_id.clone(), + machine_label: row.endpoint.label.to_owned(), + stale: row.endpoint.stale(), + agent, }) - .collect() + }) + .collect() } diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs index 4f528c3a27..38ad06218f 100644 --- a/src/client/shell/endpoint_navigation.rs +++ b/src/client/shell/endpoint_navigation.rs @@ -187,6 +187,7 @@ impl ClientShellState { ) { let agents = super::aggregate_navigation::online_agent_targets( &self.endpoints, + &self.active_endpoint_id, self.config.agent_panel_sort, ); if agents.is_empty() { diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 1b890b97be..d3823d54ce 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -1,5 +1,13 @@ use super::*; +#[derive(Clone, Debug)] +pub(crate) struct ClientEndpointAgentViewProjection { + generation: Option, + boot_id: String, + revision: u64, + pub(crate) view: Result, ()>, +} + #[derive(Clone, Debug)] pub(crate) struct ClientShellEndpoint { pub(crate) endpoint_id: ClientEndpointId, @@ -10,6 +18,9 @@ pub(crate) struct ClientShellEndpoint { pub(crate) snapshot_generation: Option, pub(crate) agent_recency: HashMap, pub(super) agent_presentation: super::endpoint_agent_state::EndpointAgentPresentation, + pub(crate) agent_view_projection: Option, + pending_agent_view_projection: Option, + pub(crate) agent_view_projection_supported: bool, pub(crate) methods: Option>, } @@ -64,6 +75,12 @@ impl ClientShellState { agent_presentation: previous .map(|endpoint| endpoint.agent_presentation.clone()) .unwrap_or_default(), + agent_view_projection: previous + .and_then(|endpoint| endpoint.agent_view_projection.clone()), + pending_agent_view_projection: previous + .and_then(|endpoint| endpoint.pending_agent_view_projection.clone()), + agent_view_projection_supported: previous + .is_some_and(|endpoint| endpoint.agent_view_projection_supported), methods: previous.and_then(|endpoint| endpoint.methods.clone()), }); } @@ -103,6 +120,9 @@ impl ClientShellState { endpoint.methods = None; endpoint.agent_recency.clear(); endpoint.agent_presentation = Default::default(); + endpoint.agent_view_projection = None; + endpoint.pending_agent_view_projection = None; + endpoint.agent_view_projection_supported = false; } } @@ -133,6 +153,24 @@ impl ClientShellState { } } + pub(crate) fn set_endpoint_agent_view_projection_supported( + &mut self, + endpoint_id: &ClientEndpointId, + supported: bool, + ) { + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.agent_view_projection_supported = supported; + if !supported { + endpoint.agent_view_projection = None; + endpoint.pending_agent_view_projection = None; + } + } + } + pub(crate) fn set_endpoint_methods_for( &mut self, endpoint_id: &ClientEndpointId, @@ -170,12 +208,18 @@ impl ClientShellState { let Some(snapshot) = endpoint.snapshot.clone() else { return false; }; - if endpoint_id != &self.active_endpoint_id { + let switching_endpoint = endpoint_id != &self.active_endpoint_id; + let agent_scroll = self.agent_scroll; + if switching_endpoint { self.active_endpoint_id = endpoint_id.clone(); self.pane_surface = None; self.pending_pane_surface = None; } self.apply_active_snapshot(snapshot); + if switching_endpoint { + // The aggregate agent list belongs to the client, not one endpoint. + self.agent_scroll = agent_scroll; + } true } @@ -251,6 +295,120 @@ impl ClientShellState { .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision)) } + pub(crate) fn set_endpoint_agent_view_projection_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + projection: crate::client::endpoint::DecodedAgentViewProjection, + ) { + self.set_endpoint_agent_view_projection( + endpoint_id, + Some(generation), + projection.boot_id, + projection.revision, + projection.view, + ); + } + + #[cfg(test)] + pub(crate) fn set_test_endpoint_agent_view( + &mut self, + endpoint_id: &ClientEndpointId, + view: Option, + ) { + let Some((boot_id, revision)) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| { + endpoint + .snapshot + .as_deref() + .map(|snapshot| (snapshot.boot_id.clone(), snapshot.revision)) + }) + else { + return; + }; + self.set_endpoint_agent_view_projection_supported(endpoint_id, true); + self.set_endpoint_agent_view_projection(endpoint_id, None, boot_id, revision, Ok(view)); + } + + #[cfg(test)] + pub(crate) fn set_test_endpoint_agent_view_projection( + &mut self, + endpoint_id: &ClientEndpointId, + boot_id: &str, + revision: u64, + view: Option, + ) { + self.set_endpoint_agent_view_projection( + endpoint_id, + None, + boot_id.to_owned(), + revision, + Ok(view), + ); + } + + fn set_endpoint_agent_view_projection( + &mut self, + endpoint_id: &ClientEndpointId, + generation: Option, + boot_id: String, + revision: u64, + view: Result, ()>, + ) { + let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + else { + return; + }; + if endpoint.snapshot_generation == generation + && endpoint + .snapshot + .as_deref() + .is_some_and(|snapshot| snapshot.boot_id == boot_id && snapshot.revision > revision) + { + return; + } + let next = ClientEndpointAgentViewProjection { + generation, + boot_id, + revision, + view, + }; + let matches_snapshot = endpoint.snapshot_generation == next.generation + && endpoint.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.boot_id == next.boot_id && snapshot.revision == next.revision + }); + let slot = if matches_snapshot { + &mut endpoint.agent_view_projection + } else { + &mut endpoint.pending_agent_view_projection + }; + if slot.as_ref().is_some_and(|current| { + current.generation == next.generation + && current.boot_id == next.boot_id + && current.revision >= next.revision + }) { + return; + } + *slot = Some(next); + } + + pub(crate) fn endpoint_agent_view( + endpoint: &ClientShellEndpoint, + ) -> Option<&Result, ()>> { + let snapshot = endpoint.snapshot.as_deref()?; + let projection = endpoint.agent_view_projection.as_ref()?; + (projection.generation == endpoint.snapshot_generation + && projection.boot_id == snapshot.boot_id + && projection.revision == snapshot.revision) + .then_some(&projection.view) + } + /// A terminal normally starts focused. `None` means this host cannot report focus events, /// not that the endpoint has no viewer; activation therefore sends an explicit true baseline. pub(crate) fn host_focus_baseline(&self) -> bool { @@ -420,6 +578,35 @@ impl ClientShellState { endpoint.agent_recency = recency; endpoint.snapshot_generation = generation; endpoint.snapshot = Some(snapshot); + let pending_matches = + endpoint + .pending_agent_view_projection + .as_ref() + .is_some_and(|projection| { + projection.generation == generation + && endpoint.snapshot.as_deref().is_some_and(|snapshot| { + projection.boot_id == snapshot.boot_id + && projection.revision == snapshot.revision + }) + }); + if pending_matches { + endpoint.agent_view_projection = endpoint.pending_agent_view_projection.take(); + } else { + endpoint.pending_agent_view_projection = None; + if endpoint + .agent_view_projection + .as_ref() + .is_some_and(|projection| { + projection.generation != generation + || endpoint.snapshot.as_deref().is_some_and(|snapshot| { + projection.boot_id != snapshot.boot_id + || projection.revision != snapshot.revision + }) + }) + { + endpoint.agent_view_projection = None; + } + } } pub(crate) fn acknowledge_active_surface_agents(&mut self, surface: &PaneSurfaceFrame) -> bool { @@ -502,6 +689,9 @@ pub(super) fn local_endpoint() -> ClientShellEndpoint { snapshot_generation: None, agent_recency: HashMap::new(), agent_presentation: Default::default(), + agent_view_projection: None, + pending_agent_view_projection: None, + agent_view_projection_supported: false, methods: None, } } diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs index 31061c9b42..a7ae81e33b 100644 --- a/src/client/shell/graphics.rs +++ b/src/client/shell/graphics.rs @@ -31,19 +31,13 @@ impl ClientShellState { }; } - pub(super) fn compose_graphics(&mut self, frame: &mut FrameData, layout: ClientShellLayout) { - let local_cover = self.overlay.is_some() - || self.mode != ClientShellMode::Terminal - || self.endpoint_error.is_some() - || self.config_diagnostic.is_some() - || self.visible_endpoint_notice.is_some() - || self.visible_notification.is_some() - || self.copy_feedback.is_some() - || self - .selection - .as_ref() - .is_some_and(|selection| selection.is_visible()); - let visibility = if local_cover { + pub(super) fn compose_graphics( + &mut self, + frame: &mut FrameData, + layout: ClientShellLayout, + occlusion: &crate::kitty_graphics::surface::Occlusion, + ) { + let visibility = if self.endpoint_error.is_some() { crate::kitty_graphics::surface::Visibility::Hidden } else if self.hits.popup.is_some() { crate::kitty_graphics::surface::Visibility::Popup @@ -60,6 +54,7 @@ impl ClientShellState { (layout.pane_surface.x, layout.pane_surface.y), popup_origin, self.graphics_cell_size, + occlusion, ); } } diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index fc997bf60f..603d0c17b4 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -84,7 +84,7 @@ impl ClientShellState { ) } - #[cfg(any(unix, test))] + #[cfg(test)] pub(crate) fn handle_input_bytes(&mut self, data: &[u8]) -> ClientShellInput { self.handle_raw_events(crate::raw_input::parse_raw_input_bytes_sync(data)) } @@ -155,9 +155,10 @@ impl ClientShellState { outcome } - pub(super) fn handle_raw_events(&mut self, events: Vec) -> ClientShellInput { + pub(crate) fn handle_raw_events(&mut self, events: Vec) -> ClientShellInput { let mut outcome = ClientShellInput::default(); if !events.is_empty() && self.endpoint_error.take().is_some() { + self.endpoint_error_deadline = None; outcome.repaint = true; } for event in events { @@ -246,6 +247,7 @@ impl ClientShellState { .push(ClientMessage::ClientShellFocus { focused: true }); } RawInputEvent::OuterFocusLost => { + outcome.repaint |= self.clear_link_hover(); self.outer_focused = Some(false); self.release_input_leases(&mut outcome); outcome @@ -300,6 +302,7 @@ impl ClientShellState { key: crate::input::TerminalKey, outcome: &mut ClientShellInput, ) { + outcome.repaint |= self.clear_link_hover(); // Layout-independent shortcuts: normalize `ctrl+ц` to `ctrl+w` before // lease tracking, keybind matching, and pane forwarding so press, // repeat, and release share one identity. Plain typing is untouched. @@ -872,6 +875,7 @@ impl ClientShellState { KeybindMatch::Action(KeybindAction::FocusAgent(index)) => { super::aggregate_navigation::online_agent_targets( &self.endpoints, + &self.active_endpoint_id, self.config.agent_panel_sort, ) .get(*index) diff --git a/src/client/shell/link_hover.rs b/src/client/shell/link_hover.rs new file mode 100644 index 0000000000..1a89d7069e --- /dev/null +++ b/src/client/shell/link_hover.rs @@ -0,0 +1,368 @@ +use super::*; +use crate::api::schema::{PaneLinkActivateParams, PaneLinkRegion}; +use crate::protocol::SurfaceRect; +use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct LinkHoverTarget { + pane_id: String, + inner_rect: Rect, + source_rect: SurfaceRect, + content_revision: u64, + offset_from_bottom: Option, + row: u16, + col: u16, +} + +impl LinkHoverTarget { + fn same_content(&self, other: &Self) -> bool { + self.pane_id == other.pane_id + && self.inner_rect == other.inner_rect + && self.source_rect == other.source_rect + && self.content_revision == other.content_revision + && self.offset_from_bottom == other.offset_from_bottom + } +} + +pub(super) struct LinkHover { + target: LinkHoverTarget, + pub(super) regions: Vec, + resolved: bool, +} + +impl ClientShellState { + pub(super) fn clear_link_hover(&mut self) -> bool { + self.link_hover + .take() + .is_some_and(|hover| !hover.regions.is_empty()) + } + + fn link_hover_allowed(&self) -> bool { + self.mode == ClientShellMode::Terminal + && self.overlay.is_none() + && self.popup_terminal_id.is_none() + && self.outer_focused != Some(false) + && self.chrome_drag.is_none() + && self.pane_mouse_gesture.is_none() + } + + fn link_hover_target_current(&self, target: &LinkHoverTarget) -> bool { + self.link_hover_allowed() + && self.pane_surface.as_ref().is_some_and(|surface| { + self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision == snapshot.revision + }) && surface.panes.iter().any(|pane| { + pane.pane_id == target.pane_id + && pane.inner_rect == target.source_rect + && pane.content_revision == target.content_revision + && pane.scroll.map(|scroll| scroll.offset_from_bottom) + == target.offset_from_bottom + }) + }) + && self + .hits + .panes + .iter() + .any(|hit| hit.pane_id == target.pane_id && hit.inner_rect == target.inner_rect) + } + + pub(super) fn invalidate_link_hover(&mut self) { + if self + .link_hover + .as_ref() + .is_some_and(|hover| !self.link_hover_target_current(&hover.target)) + { + self.clear_link_hover(); + } + } + + pub(super) fn update_link_hover(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + if mouse.kind != MouseEventKind::Moved + || !mouse.modifiers.contains(KeyModifiers::CONTROL) + || !self.link_hover_allowed() + { + outcome.repaint |= self.clear_link_hover(); + return; + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| contains(hit.inner_rect, (mouse.column, mouse.row))) + else { + outcome.repaint |= self.clear_link_hover(); + return; + }; + let Some(pane) = self.pane_surface.as_ref().and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) else { + outcome.repaint |= self.clear_link_hover(); + return; + }; + let target = LinkHoverTarget { + pane_id: hit.pane_id.clone(), + inner_rect: hit.inner_rect, + source_rect: pane.inner_rect, + content_revision: pane.content_revision, + offset_from_bottom: pane.scroll.map(|scroll| scroll.offset_from_bottom), + row: mouse.row.saturating_sub(hit.inner_rect.y), + col: mouse.column.saturating_sub(hit.inner_rect.x), + }; + if !self.link_hover_target_current(&target) || !target.content_revision.is_multiple_of(2) { + outcome.repaint |= self.clear_link_hover(); + return; + } + if self.link_hover.as_ref().is_some_and(|hover| { + hover.target.same_content(&target) + && (hover.target == target + || hover + .regions + .iter() + .any(|region| region_contains(region, target.col, target.row))) + }) { + self.request_link_hover(outcome); + return; + } + outcome.repaint |= self.clear_link_hover(); + let explicit = self.explicit_link_regions(&target); + let resolved = explicit.is_some(); + let regions = explicit.unwrap_or_default(); + outcome.repaint |= !regions.is_empty(); + self.link_hover = Some(LinkHover { + target, + regions, + resolved, + }); + self.request_link_hover(outcome); + } + + fn request_link_hover(&mut self, outcome: &mut ClientShellInput) { + if self + .pending_requests + .values() + .any(|pending| matches!(pending.kind, PendingEndpointKind::PaneLinkResolve { .. })) + { + return; + } + let Some(hover) = self.link_hover.as_ref().filter(|hover| !hover.resolved) else { + return; + }; + if !self.link_hover_target_current(&hover.target) { + return; + } + let target = hover.target.clone(); + // Hover is optional and speculative: do not probe unadvertised methods or show notices. + if !self.endpoint_is_online(&self.active_endpoint_id) + || !self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .and_then(|endpoint| endpoint.methods.as_ref()) + .is_some_and(|methods| methods.iter().any(|method| method == "pane.link.resolve")) + { + if let Some(hover) = self.link_hover.as_mut() { + hover.resolved = true; + } + return; + } + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkResolve(PaneLinkActivateParams { + pane_id: target.pane_id.clone(), + viewport_row: target.row, + col: target.col, + content_revision: Some(target.content_revision), + offset_from_bottom: target.offset_from_bottom, + }), + PendingEndpointKind::PaneLinkResolve { target }, + outcome, + ); + } + + pub(super) fn complete_link_hover( + &mut self, + target: LinkHoverTarget, + result: Result, + ) -> (bool, Vec) { + let cancelled = result + .as_ref() + .err() + .is_some_and(|error| error.code.as_deref() == Some("endpoint_cancelled")); + let mut outcome = ClientShellInput::default(); + if self.link_hover_target_current(&target) + && self + .link_hover + .as_ref() + .is_some_and(|hover| hover.target == target) + { + let regions = match result { + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { regions }) + if regions.len() <= usize::from(target.inner_rect.height) + && regions.iter().all(|region| { + region.row < target.inner_rect.height + && region.start_col <= region.end_col + && region.end_col < target.inner_rect.width + }) + && regions + .iter() + .any(|region| region_contains(region, target.col, target.row)) => + { + regions + } + _ => Vec::new(), + }; + outcome.repaint = !regions.is_empty(); + self.link_hover = Some(LinkHover { + target, + regions, + resolved: true, + }); + } + if !cancelled { + self.request_link_hover(&mut outcome); + } + (outcome.repaint, outcome.actions) + } + + fn explicit_link_regions(&self, target: &LinkHoverTarget) -> Option> { + let surface = self.pane_surface.as_ref()?; + let rect = target.source_rect; + let cell = |col: u16, row: u16| { + let x = usize::from(rect.x) + usize::from(col); + let y = usize::from(rect.y) + usize::from(row); + if x >= usize::from(surface.frame.width) || y >= usize::from(surface.frame.height) { + return None; + } + surface + .frame + .cells + .get(y * usize::from(surface.frame.width) + x) + }; + let hyperlink_at = |col: u16, row: u16| { + let current = cell(col, row)?; + current.hyperlink.or_else(|| { + // Rendered wide-cell spacers may not carry their own OSC 8 metadata. + if col == 0 || !current.symbol.trim().is_empty() { + return None; + } + let previous = cell(col - 1, row)?; + (previous.symbol.width() == 2) + .then_some(previous.hyperlink) + .flatten() + }) + }; + let hyperlink = hyperlink_at(target.col, target.row)?; + let uri = surface.frame.hyperlinks.get(hyperlink as usize)?; + if crate::app::actions::safe_web_url(uri).is_none() { + return Some(Vec::new()); + } + let width = usize::from(rect.width); + if width == 0 { + return Some(Vec::new()); + } + let clicked = usize::from(target.row) * width + usize::from(target.col); + let mut start = clicked; + let mut end = clicked; + let matches = |index: usize| { + hyperlink_at((index % width) as u16, (index / width) as u16) == Some(hyperlink) + }; + // Frame IDs identify destinations, not OSC 8 runs. Adjacent cells with + // the same destination form one hover region, including across rows. + let mut remaining = 8191usize; + while start > 0 && matches(start - 1) { + if remaining == 0 { + return Some(Vec::new()); + } + remaining -= 1; + start -= 1; + } + while end + 1 < width * usize::from(rect.height) && matches(end + 1) { + if remaining == 0 { + return Some(Vec::new()); + } + remaining -= 1; + end += 1; + } + let regions = (start / width..=end / width) + .map(|row| PaneLinkRegion { + row: row as u16, + start_col: if row == start / width { + (start % width) as u16 + } else { + 0 + }, + end_col: if row == end / width { + (end % width) as u16 + } else { + rect.width - 1 + }, + }) + .collect(); + Some(regions) + } + + pub(super) fn link_hover_blocks_patch( + &self, + patch: &crate::protocol::PaneSurfacePatch, + ) -> bool { + self.link_hover.as_ref().is_some_and(|hover| { + !hover.regions.is_empty() + && patch + .panes + .iter() + .any(|pane| pane.pane_id == hover.target.pane_id) + }) + } + + pub(super) fn render_link_hover( + &self, + frame: &mut FrameData, + occlusion: &mut crate::kitty_graphics::surface::Occlusion, + ) { + let Some(hover) = self + .link_hover + .as_ref() + .filter(|hover| self.link_hover_target_current(&hover.target)) + else { + return; + }; + let rect = hover.target.inner_rect; + for region in &hover.regions { + let row = rect.y.saturating_add(region.row); + if row >= frame.height { + continue; + } + occlusion.cover( + Rect::new( + rect.x.saturating_add(region.start_col), + row, + region + .end_col + .saturating_sub(region.start_col) + .saturating_add(1), + 1, + ) + .intersection(Rect::new(0, 0, frame.width, frame.height)), + ); + for col in region.start_col..=region.end_col { + let col = rect.x.saturating_add(col); + if col >= frame.width { + continue; + } + if let Some(cell) = frame + .cells + .get_mut(usize::from(row) * usize::from(frame.width) + usize::from(col)) + { + cell.modifier |= Modifier::UNDERLINED.bits(); + } + } + } + } +} + +fn region_contains(region: &PaneLinkRegion, col: u16, row: u16) -> bool { + region.row == row && col >= region.start_col && col <= region.end_col +} diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 95036de583..185bef51d8 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -610,8 +610,11 @@ fn mobile_items( }); } } - let agents = - super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort); + let agents = super::aggregate_navigation::aggregate_agent_rows( + endpoints, + active_endpoint_id, + config.agent_panel_sort, + ); let agent_view_label = snapshot.agent_view_label.as_deref(); if !agents.is_empty() || agent_view_label.is_some() { let title = agent_view_label diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index bd1dc0edcd..ccfee3fedf 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -137,8 +137,7 @@ impl ClientShellState { Ok(_) => { self.pane_scroll_queued.remove(&pane_id); self.pane_scroll_targets.remove(&pane_id); - self.endpoint_error = - Some("endpoint returned an unexpected pane-scroll result".to_owned()); + self.set_endpoint_error("endpoint returned an unexpected pane-scroll result"); true } Err(_) => { @@ -667,6 +666,7 @@ impl ClientShellState { } pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + self.update_link_hover(mouse, outcome); let point = (mouse.column, mouse.row); if self.mode == ClientShellMode::Navigate && self.workspace_preview_action_blocked() diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index 4418c2c32e..bc3772b1b5 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -5,6 +5,8 @@ mod worktree_overlays; #[derive(Default)] pub(crate) struct OverlayRender { + pub(crate) area: Rect, + pub(crate) menu_rows: Vec<(Rect, usize)>, pub(crate) primary: Rect, pub(crate) clear: Rect, pub(crate) cancel: Rect, @@ -85,7 +87,7 @@ pub(crate) fn render_global_menu( menu: &ClientGlobalMenuOverlay, snapshot: &ClientShellSnapshot, palette: &Palette, -) -> Option> { +) -> Option { let items = super::super::global_menu::global_menu_items(snapshot); let screen = buffer.area; let width = items @@ -108,12 +110,8 @@ pub(crate) fn render_global_menu( .saturating_sub(width) .min(screen.right().saturating_sub(width)); let y = launcher.y.saturating_sub(height).max(screen.y); - let inner = panel( - buffer, - Rect::new(x, y, width, height), - palette.accent, - palette.panel_bg, - )?; + let rect = Rect::new(x, y, width, height); + let inner = panel(buffer, rect, palette.accent, palette.panel_bg)?; let mut rows = Vec::new(); for (index, (label, action)) in items.iter().enumerate() { let row_y = inner.y.saturating_add(index as u16); @@ -155,14 +153,18 @@ pub(crate) fn render_global_menu( } rows.push((row, index)); } - Some(rows) + Some(OverlayRender { + area: rect, + menu_rows: rows, + ..OverlayRender::default() + }) } pub(crate) fn render_context_menu( buffer: &mut Buffer, menu: &ClientContextMenuOverlay, palette: &Palette, -) -> Option> { +) -> Option { let items = menu.items(); let screen = buffer.area; let max_item_width = items @@ -207,7 +209,11 @@ pub(crate) fn render_context_menu( put_text(buffer, row.x, row.y, row.width, item.label, style); rows.push((row, index)); } - Some(rows) + Some(OverlayRender { + area: rect, + menu_rows: rows, + ..OverlayRender::default() + }) } fn panel( @@ -306,7 +312,10 @@ fn render_release_notes_overlay( )?; let inner = panel(b, outer, p.accent, p.panel_bg)?; if inner.height < 8 || inner.width < 20 { - return Some(OverlayRender::default()); + return Some(OverlayRender { + area: outer, + ..OverlayRender::default() + }); } let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); @@ -397,6 +406,7 @@ fn render_release_notes_overlay( } Some(OverlayRender { + area: outer, primary: close, release_notes_scrollbar: track.unwrap_or_default(), release_notes_scroll_metrics: Some(metrics), @@ -417,7 +427,10 @@ fn render_product_announcement_overlay( )?; let inner = panel(b, outer, p.accent, p.panel_bg)?; if inner.height < 8 || inner.width < 20 { - return Some(OverlayRender::default()); + return Some(OverlayRender { + area: outer, + ..OverlayRender::default() + }); } let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); @@ -509,6 +522,7 @@ fn render_product_announcement_overlay( } Some(OverlayRender { + area: outer, primary: close, product_announcement_scrollbar: track.unwrap_or_default(), product_announcement_scroll_metrics: Some(metrics), @@ -521,7 +535,10 @@ fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option Option bool { if let Err(error) = crate::config::write_edit(edit) { - self.endpoint_error = Some(error); + self.set_endpoint_error(error); outcome.repaint = true; return false; } @@ -303,8 +303,8 @@ impl ClientShellState { .min(settings.integrations.len().saturating_sub(1)); } Ok(_) => { - self.endpoint_error = Some( - "endpoint returned an unexpected integration list result".into(), + self.set_endpoint_error( + "endpoint returned an unexpected integration list result", ); } Err(_) => {} diff --git a/src/client/shell/settings_overlay.rs b/src/client/shell/settings_overlay.rs index ea59ad0297..25f37b2a0e 100644 --- a/src/client/shell/settings_overlay.rs +++ b/src/client/shell/settings_overlay.rs @@ -244,6 +244,7 @@ pub(super) fn render_settings_overlay( ); Some(OverlayRender { + area: popup, primary, cancel: close, settings_popup: popup, @@ -316,7 +317,7 @@ fn render_integrations( area.x, area.y + 1, area.width, - "let agents report state directly instead of relying only on process detection", + "enable session restore and, where supported, direct status updates", Style::default().fg(palette.overlay1).bg(palette.panel_bg), ); if settings.loading_integrations { diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index e685a82539..13b8774210 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -726,9 +726,7 @@ pub(in crate::client::shell) fn render_workspace_rows( status_icon(status, indicators), Style::default().fg(status_color(status, palette)), ), - Style::default() - .fg(status_color(status, palette)) - .add_modifier(Modifier::DIM), + Style::default().fg(status_color(status, palette)), workspace_style, secondary_style, Style::default().fg(palette.overlay1), diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index e9143c7b5c..7d64e26973 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -3,6 +3,7 @@ use super::*; pub(super) const MIN_TAB_WIDTH: u16 = 8; pub(super) const NEW_TAB_WIDTH: u16 = 3; pub(super) const WORKSPACE_HEADER_ROWS: u16 = 2; +const ENDPOINT_ERROR_TIMEOUT_SECS: u64 = 5; fn pane_surface_row<'a>( surface: &'a PaneSurfaceFrame, @@ -708,6 +709,9 @@ pub(super) enum PendingEndpointKind { absolute_row: u32, generation: u64, }, + PaneLinkResolve { + target: super::link_hover::LinkHoverTarget, + }, PaneLinkActivate { pane_id: String, inner_rect: Rect, @@ -943,6 +947,7 @@ pub(crate) struct ClientShellState { pub(super) overlay: Option, pub(super) previous_pane_id: Option, pub(super) pane_mouse_gesture: Option, + pub(super) link_hover: Option, pub(super) url_click_consumes_until_up: bool, pub(super) replaying_url_click: bool, pub(super) selection: Option>, @@ -984,6 +989,7 @@ pub(crate) struct ClientShellState { pub(super) local_config_diagnostic: Option, pub(super) config_diagnostic: Option, pub(super) endpoint_error: Option, + pub(super) endpoint_error_deadline: Option, pub(super) dismissed_product_announcement: Option<(String, String)>, } @@ -1101,6 +1107,7 @@ impl ClientShellState { overlay, previous_pane_id: None, pane_mouse_gesture: None, + link_hover: None, url_click_consumes_until_up: false, replaying_url_click: false, selection: None, @@ -1142,6 +1149,7 @@ impl ClientShellState { config_diagnostic: local_config_diagnostic.clone(), local_config_diagnostic, endpoint_error: None, + endpoint_error_deadline: None, dismissed_product_announcement: None, } } @@ -1283,6 +1291,7 @@ impl ClientShellState { self.endpoint_notice_seen.clear(); self.visible_endpoint_notice = None; self.endpoint_error = None; + self.endpoint_error_deadline = None; self.navigate_workspace_id = None; self.overlay = self .config @@ -1290,6 +1299,7 @@ impl ClientShellState { .then_some(ClientShellOverlay::Onboarding); self.previous_pane_id = None; self.pane_mouse_gesture = None; + self.link_hover = None; self.url_click_consumes_until_up = false; self.replaying_url_click = false; self.selection = None; @@ -1399,7 +1409,7 @@ impl ClientShellState { snapshot.server_keybindings_toml.as_deref(), &snapshot.commands, ) { - self.endpoint_error = Some(err); + self.set_endpoint_error(err); } else if active_keymap_changed && matches!( self.mode, @@ -1703,6 +1713,7 @@ impl ClientShellState { } self.hits.popup = None; self.endpoint_error = None; + self.endpoint_error_deadline = None; } if next_popup.is_some() { self.popup_pending = false; @@ -1816,6 +1827,7 @@ impl ClientShellState { self.graphics .set_scene(std::mem::take(&mut surface.graphics)); self.pane_surface = Some(surface); + self.invalidate_link_hover(); self.resume_mobile_switcher_if_ready(); self.reconcile_input_source(); } @@ -1862,6 +1874,33 @@ impl ClientShellState { repaint } + /// Show a transient client-side action error, restarting its lifetime. + /// + /// Every assignment must go through this setter so a repeated identical + /// message gets a fresh deadline instead of inheriting the previous one. + pub(super) fn set_endpoint_error(&mut self, message: impl Into) { + self.endpoint_error = Some(message.into()); + self.endpoint_error_deadline = Some( + std::time::Instant::now() + std::time::Duration::from_secs(ENDPOINT_ERROR_TIMEOUT_SECS), + ); + } + + pub(crate) fn tick_endpoint_error(&mut self, now: std::time::Instant) -> bool { + if self.endpoint_error.is_none() { + self.endpoint_error_deadline = None; + return false; + } + if self + .endpoint_error_deadline + .is_some_and(|deadline| now >= deadline) + { + self.endpoint_error = None; + self.endpoint_error_deadline = None; + return true; + } + false + } + pub(crate) fn timer_delay(&self, now: std::time::Instant) -> std::time::Duration { let default = std::time::Duration::from_millis(100); self.selection_autoscroll_deadline diff --git a/src/client/shell/surface_patch.rs b/src/client/shell/surface_patch.rs index 784fbcd8cf..87f947faca 100644 --- a/src/client/shell/surface_patch.rs +++ b/src/client/shell/surface_patch.rs @@ -72,6 +72,8 @@ fn fast_path_blocker( Some("client_surface_patch.fallback.notification") } else if state.copy_feedback.is_some() { Some("client_surface_patch.fallback.copy_feedback") + } else if state.link_hover_blocks_patch(patch) { + Some("client_surface_patch.fallback.link_hover") } else if state.selection.is_some() { Some("client_surface_patch.fallback.selection") } else if state.copy_mode.is_some() { @@ -240,6 +242,7 @@ impl ClientShellState { } } } + self.invalidate_link_hover(); self.reconcile_input_source(); } else { let mut next = current.clone(); diff --git a/src/client/shell/tabs.rs b/src/client/shell/tabs.rs index cf179d8080..9e9901ff0b 100644 --- a/src/client/shell/tabs.rs +++ b/src/client/shell/tabs.rs @@ -112,10 +112,7 @@ pub(crate) fn render_tab_bar( } else if tab.custom_label { Style::default().fg(palette.overlay1).bg(palette.surface0) } else { - Style::default() - .fg(palette.overlay0) - .bg(palette.surface0) - .add_modifier(Modifier::DIM) + Style::default().fg(palette.overlay0).bg(palette.surface0) }; let padding = width.saturating_sub(display_width(&name)); let left = padding / 2; diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index a8e1164524..36f361ab27 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -574,6 +574,67 @@ fn agent_sidebar_honors_priority_symbols_tokens_and_stable_hits() { assert_eq!(compact.cells[row_start].bg, compact.cells[row_start + 2].bg); } +#[test] +fn muted_agent_sidebar_rows_do_not_stack_terminal_faint() { + let mut projected = snapshot(); + projected.tabs[0].label = "second".into(); + projected.tabs[0].custom_label = true; + projected.agents = vec![ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("reviewer".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 30).expect("agent sidebar frame"); + let row = state.hits.agents.first().expect("agent row hit").0; + let buffer = frame.to_ratatui_buffer().expect("agent sidebar buffer"); + + for (label, needle) in [("tab", "second"), ("agent", "reviewer"), ("separator", "·")] { + let (x, y) = cell_symbol_position(&frame, row, needle); + let cell = buffer.cell((x, y)).expect("muted sidebar cell"); + assert!( + !cell.modifier.contains(Modifier::DIM), + "{label} cell at ({x},{y}) should not stack terminal faint: {cell:?}" + ); + } +} + +#[test] +fn workspace_state_text_does_not_stack_terminal_faint() { + use crate::config::SpaceSidebarToken; + + let mut config = Config::default(); + config.ui.sidebar.spaces.rows = vec![ + vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace], + vec![SpaceSidebarToken::StateText], + ]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let frame = state.compose(106, 30).expect("workspace sidebar frame"); + let rect = state.hits.workspaces.first().expect("workspace hit").rect; + let buffer = frame.to_ratatui_buffer().expect("workspace sidebar buffer"); + let (x, y) = cell_symbol_position(&frame, rect, "idle"); + let cell = buffer.cell((x, y)).expect("workspace state text cell"); + assert!( + !cell.modifier.contains(Modifier::DIM), + "workspace state text at ({x},{y}) should not stack terminal faint: {cell:?}" + ); +} + #[test] fn active_agent_view_controls_sidebar_order_and_focus_indices() { let mut projected = snapshot(); @@ -1029,6 +1090,71 @@ fn unavailable_worktree_create_does_not_wedge_the_overlay() { .is_some_and(|notice| notice.key.code == "worktree.create")); } +#[test] +fn worktree_action_errors_expire_without_more_input() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let mut guard = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::RemoveWorktree), + &mut guard, + ); + let message = "This workspace is not a Herdr-managed worktree checkout."; + assert_eq!(state.endpoint_error.as_deref(), Some(message)); + + let deadline = state.endpoint_error_deadline.expect("deadline"); + assert!(!state.tick_endpoint_error(deadline - std::time::Duration::from_secs(1))); + assert_eq!(state.endpoint_error.as_deref(), Some(message)); + + assert!(state.tick_endpoint_error(deadline + std::time::Duration::from_millis(1))); + assert!(state.endpoint_error.is_none()); + + // A repeated identical message must start a fresh lifetime instead of + // inheriting the earlier deadline. + let before_repeat = std::time::Instant::now(); + state.set_endpoint_error(message); + assert!( + state.endpoint_error_deadline.expect("deadline") + >= before_repeat + std::time::Duration::from_secs(5) + ); +} + +#[test] +fn worktree_prepare_rejection_notice_expires() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("new worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("not_git_worktree".into()), + message: "Herdr worktree actions require a workspace inside a Git work tree".into(), + }), + ); + let notice = state + .visible_endpoint_notice + .as_ref() + .expect("rejection notice"); + assert!(notice.key.code.contains("not_git_worktree")); + let deadline = notice.deadline; + + let (_, repaint) = state.tick_notifications(deadline + std::time::Duration::from_millis(1)); + assert!(repaint); + assert!(state.visible_endpoint_notice.is_none()); +} + #[test] fn worktree_open_filters_and_clicks_a_stable_public_entry() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 2e5e87acbb..1edba8e692 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -40,6 +40,24 @@ fn agent( } } +fn current_workspace_view() -> crate::api::schema::AgentViewSetParams { + use crate::api::schema::{ + AgentViewBuiltinField, AgentViewContext, AgentViewField, AgentViewFilter, AgentViewValue, + }; + + crate::api::schema::AgentViewSetParams { + source: "example.views".into(), + label: Some("current space".into()), + filter: Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId), + value: AgentViewValue::Context { + context: AgentViewContext::CurrentWorkspaceId, + }, + }), + sort: Vec::new(), + } +} + fn state_with_remote() -> (ClientShellState, ClientEndpointId) { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); let profile = remote_profile(); @@ -55,6 +73,115 @@ fn state_with_remote() -> (ClientShellState, ClientEndpointId) { (state, endpoint_id) } +fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) { + let (mut state, remote) = state_with_remote(); + for endpoint_id in [ClientEndpointId::Local, remote.clone()] { + let mut projection = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap() + .snapshot + .clone() + .unwrap(); + projection.agents = (0..8) + .map(|index| ClientShellAgent { + pane_id: format!("pane_{}", index + 1), + focused: index == 0, + ..agent(&format!("agent {index}"), AgentStatus::Idle, 1) + }) + .collect(); + projection.panes = projection + .agents + .iter() + .map(|agent| ClientShellPane { + pane_id: agent.pane_id.clone(), + focused: agent.focused, + ..projection.panes[0].clone() + }) + .collect(); + state.set_endpoint_snapshot(&endpoint_id, projection); + } + state.compose(100, 28).unwrap(); + state.agent_scroll = 6; + state.compose(100, 28).unwrap(); + assert_eq!(state.agent_scroll, 6); + (state, remote) +} + +#[test] +fn switching_machines_preserves_aggregate_agent_scroll_and_visible_rows() { + let (mut state, remote) = state_with_scrollable_agents(); + for endpoint_id in [remote.clone(), ClientEndpointId::Local, remote] { + let visible = state.hits.endpoint_agents.clone(); + let (rect, _, pane_id) = visible + .iter() + .find(|(_, endpoint, _)| endpoint == &endpoint_id) + .expect("destination agent remains visible"); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x + 2, + row: rect.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(matches!( + click.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: target, + target: Some(ClientEndpointFocusTarget::Pane(target_pane)), + }] if target == &endpoint_id && target_pane == pane_id + )); + + state.workspace_scroll = 3; + state.tab_scroll = 2; + assert!(state.activate_endpoint_projection(&endpoint_id)); + assert_eq!(state.agent_scroll, 6); + assert_eq!(state.workspace_scroll, 0); + assert_eq!(state.tab_scroll, 0); + assert!(state.pane_surface.is_none()); + + let mut next_surface = surface(); + next_surface.boot_id = state.endpoint_boot_id(&endpoint_id).unwrap().into(); + state.set_pane_surface(next_surface); + state.compose(100, 28).unwrap(); + assert_eq!(state.agent_scroll, 6); + assert_eq!(state.hits.endpoint_agents, visible); + } +} + +#[test] +fn aggregate_agent_scroll_still_clamps_when_rows_shrink_on_activation() { + let (mut state, remote) = state_with_scrollable_agents(); + for endpoint_id in [ClientEndpointId::Local, remote.clone()] { + let mut projection = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap() + .snapshot + .clone() + .unwrap(); + projection.revision += 1; + projection.agents.truncate(1); + state.set_endpoint_snapshot(&endpoint_id, projection); + } + assert!(state.activate_endpoint_projection(&remote)); + state.compose(100, 28).unwrap(); + assert_eq!(state.agent_scroll, 0); + assert_eq!(state.hits.agent_max_scroll, 0); + assert_eq!(state.hits.endpoint_agents.len(), 2); +} + +#[test] +fn same_machine_reboot_still_resets_agent_scroll() { + let (mut state, _) = state_with_scrollable_agents(); + let mut projection = state.snapshot.clone().unwrap(); + projection.boot_id = "restarted-local".into(); + state.cache_endpoint_snapshot(&ClientEndpointId::Local, projection); + assert!(state.activate_endpoint_projection(&ClientEndpointId::Local)); + assert_eq!(state.agent_scroll, 0); +} + #[test] fn switching_machines_from_copy_mode_restores_terminal_input() { let (mut state, remote) = state_with_remote(); @@ -659,6 +786,415 @@ fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() { .any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red)); } +#[test] +fn current_workspace_agent_view_excludes_same_workspace_id_on_other_machine() { + use crate::api::schema::AgentStatus; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + config.ui.sidebar.agents.rows = + vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agent_view_label = Some("current space".into()); + local.agent_order = vec!["pane_1".into()]; + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agent_view_label = Some("current space".into()); + remote.agent_order = vec!["pane_1".into()]; + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + let view = current_workspace_view(); + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view.clone())); + state.set_test_endpoint_agent_view(&endpoint_id, Some(view)); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("Local · local agent"), "frame: {text}"); + assert!(!text.contains("Build · remote agent"), "frame: {text}"); + + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + let frame = state.compose(100, 28).expect("remote endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(!text.contains("Local · local agent"), "frame: {text}"); + assert!(text.contains("Build · remote agent"), "frame: {text}"); +} + +#[test] +fn current_workspace_or_blocked_keeps_foreign_attention_only() { + use crate::api::schema::{ + AgentStatus, AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewValue, + }; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + config.ui.sidebar.agents.rows = + vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agent_view_label = Some("focus".into()); + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![ + agent("remote idle", AgentStatus::Idle, 1), + ClientShellAgent { + pane_id: "pane_2".into(), + name: Some("remote blocked".into()), + agent_status: AgentStatus::Blocked, + focused: false, + ..agent("remote blocked", AgentStatus::Blocked, 2) + }, + ]; + remote.panes.push(ClientShellPane { + pane_id: "pane_2".into(), + focused: false, + ..remote.panes[0].clone() + }); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let mut view = current_workspace_view(); + view.label = Some("focus".into()); + view.filter = Some(AgentViewFilter::Any { + filters: vec![ + view.filter.take().expect("current workspace filter"), + AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("blocked".into()), + }, + ], + }); + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view)); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("Local · local agent"), "frame: {text}"); + assert!(!text.contains("Build · remote idle"), "frame: {text}"); + assert!(text.contains("Build · remote blocked"), "frame: {text}"); +} + +#[test] +fn selected_default_view_ignores_inactive_endpoint_projection() { + use crate::api::schema::{ + AgentStatus, AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewValue, + }; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + config.ui.sidebar.agents.rows = + vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agent_view_label = Some("blocked".into()); + remote.agent_order.clear(); + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, None); + state.set_test_endpoint_agent_view( + &endpoint_id, + Some(crate::api::schema::AgentViewSetParams { + source: "remote.views".into(), + label: Some("blocked".into()), + filter: Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("blocked".into()), + }), + sort: Vec::new(), + }), + ); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("Local · local agent"), "frame: {text}"); + assert!(text.contains("Build · remote agent"), "frame: {text}"); + assert!(text.contains("grouped"), "frame: {text}"); +} + +#[test] +fn newer_snapshot_does_not_reuse_stale_agent_view_projection() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut local = snapshot(); + local.agent_view_label = Some("current space".into()); + state.set_snapshot(Box::new(local.clone())); + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(current_workspace_view())); + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .expect("local endpoint"); + assert!(matches!( + ClientShellState::endpoint_agent_view(endpoint), + Some(Ok(Some(_))) + )); + + state.set_test_endpoint_agent_view_projection( + &ClientEndpointId::Local, + "foreign-boot", + 99, + None, + ); + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .expect("local endpoint"); + assert!(matches!( + ClientShellState::endpoint_agent_view(endpoint), + Some(Ok(Some(_))) + )); + + local.revision += 1; + state.set_snapshot(Box::new(local)); + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .expect("local endpoint"); + assert!(ClientShellState::endpoint_agent_view(endpoint).is_none()); +} + +#[test] +fn legacy_custom_views_keep_v1_per_endpoint_projection() { + use crate::api::schema::AgentStatus; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + config.ui.sidebar.agents.rows = + vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agent_view_label = Some("current space".into()); + local.agent_order = vec!["pane_1".into()]; + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agent_view_label = Some("current space".into()); + remote.agent_order = vec!["pane_1".into()]; + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let frame = state.compose(100, 28).expect("legacy combined frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("Local · local agent"), "frame: {text}"); + assert!(text.contains("Build · remote agent"), "frame: {text}"); +} + +#[test] +fn selected_custom_sort_orders_rendering_and_indexed_navigation() { + use crate::api::schema::{ + AgentStatus, AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField, + AgentViewSortOrder, + }; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + config.ui.sidebar.agents.rows = + vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agent_view_label = Some("recent".into()); + local.agents = vec![agent("local blocked", AgentStatus::Blocked, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("remote idle", AgentStatus::Idle, 9)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let mut view = current_workspace_view(); + view.label = Some("recent".into()); + view.filter = None; + view.sort = vec![AgentViewSort { + field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::StateChangeSeq), + order: AgentViewSortOrder::Desc, + }]; + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view)); + + let frame = state.compose(100, 28).expect("custom sorted frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + text.find("Build · remote idle").expect("remote row") + < text.find("Local · local blocked").expect("local row"), + "frame: {text}" + ); + + let mut outcome = ClientShellInput::default(); + assert!( + state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,) + ); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: selected, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if selected == &endpoint_id && pane_id == "pane_1" + )); +} + +#[test] +fn selected_position_sort_uses_public_tab_and_pane_numbers() { + use crate::api::schema::{ + AgentStatus, AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField, + AgentViewSortOrder, + }; + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut selected = snapshot(); + selected.agent_view_label = Some("positions".into()); + + let mut tab_nine = selected.tabs[0].clone(); + tab_nine.tab_id = "ws_1:t9".into(); + tab_nine.number = 9; + let mut tab_two = tab_nine.clone(); + tab_two.tab_id = "ws_1:t2".into(); + tab_two.number = 2; + selected.tabs = vec![tab_nine, tab_two]; + + let mut pane_tab_nine = selected.panes[0].clone(); + pane_tab_nine.tab_id = "ws_1:t9".into(); + pane_tab_nine.pane_id = "ws_1:p1".into(); + let mut pane_nine = pane_tab_nine.clone(); + pane_nine.tab_id = "ws_1:t2".into(); + pane_nine.pane_id = "ws_1:p9".into(); + let mut pane_two = pane_nine.clone(); + pane_two.pane_id = "ws_1:p2".into(); + selected.panes = vec![pane_tab_nine, pane_nine, pane_two]; + + let mut late_tab = agent("tab nine", AgentStatus::Idle, 1); + late_tab.tab_id = "ws_1:t9".into(); + late_tab.pane_id = "ws_1:p1".into(); + let mut late_pane = agent("pane nine", AgentStatus::Idle, 1); + late_pane.tab_id = "ws_1:t2".into(); + late_pane.pane_id = "ws_1:p9".into(); + let mut early_pane = agent("pane two", AgentStatus::Idle, 1); + early_pane.tab_id = "ws_1:t2".into(); + early_pane.pane_id = "ws_1:p2".into(); + selected.agents = vec![late_tab, late_pane, early_pane]; + state.set_snapshot(Box::new(selected)); + + let mut view = current_workspace_view(); + view.label = Some("positions".into()); + view.filter = None; + view.sort = vec![ + AgentViewSort { + field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::TabOrder), + order: AgentViewSortOrder::Asc, + }, + AgentViewSort { + field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::PaneOrder), + order: AgentViewSortOrder::Asc, + }, + ]; + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view)); + + let names = aggregate_navigation::aggregate_agent_rows( + &state.endpoints, + &state.active_endpoint_id, + crate::config::AgentPanelSortConfig::Priority, + ) + .into_iter() + .map(|row| row.agent.name.as_deref().expect("agent name")) + .collect::>(); + assert_eq!(names, ["pane two", "pane nine", "tab nine"]); +} + #[test] fn aggregate_priority_uses_client_observed_recency_across_machines() { use crate::api::schema::AgentStatus; diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs new file mode 100644 index 0000000000..528695d129 --- /dev/null +++ b/src/client/shell/tests/graphics.rs @@ -0,0 +1,455 @@ +use super::*; +use crate::protocol::{ + SurfaceGraphicsAsset, SurfaceGraphicsAssetKey, SurfaceGraphicsFormat, SurfaceGraphicsPlacement, + SurfaceGraphicsSource, SurfaceGraphicsTarget, +}; + +fn image( + target: SurfaceGraphicsTarget, + x: u16, + y: u16, + id: u32, +) -> (SurfaceGraphicsAsset, SurfaceGraphicsPlacement) { + let key = SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::Terminal { + target, + image_id: id, + }, + image_width: 1, + image_height: 1, + format: SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint: u64::from(id), + }; + ( + SurfaceGraphicsAsset { + key: key.clone(), + data: vec![1, 2, 3, 4], + }, + SurfaceGraphicsPlacement { + asset: key, + logical_placement_id: id, + x, + y, + cols: 1, + rows: 1, + source_x: 0, + source_y: 0, + source_width: 1, + source_height: 1, + x_offset: 0, + y_offset: 0, + z: 0, + scrollback_offset: 0, + }, + ) +} + +fn add_main_image( + surface: &mut PaneSurfaceFrame, + layout: ClientShellLayout, + point: (u16, u16), + id: u32, +) { + let (asset, placement) = image( + SurfaceGraphicsTarget::Pane { + pane_id: "pane_1".into(), + }, + point.0 - layout.pane_surface.x, + point.1 - layout.pane_surface.y, + id, + ); + surface.graphics.assets.push(asset); + surface.graphics.placements.push(placement); +} + +fn is_placed(bytes: &[u8], point: (u16, u16)) -> bool { + String::from_utf8_lossy(bytes).contains(&format!("\x1b[{};{}H", point.1 + 1, point.0 + 1)) +} + +fn assert_graphics_cover(state: &mut ClientShellState, covered: Rect, cols: u16, rows: u16) { + let layout = state.layout(cols, rows); + let covered = covered.intersection(layout.pane_surface); + assert!(!covered.is_empty()); + let inside = (covered.right() - 1, covered.bottom() - 1); + let outside = (layout.pane_surface.y..layout.pane_surface.bottom()) + .find_map(|y| { + (layout.pane_surface.x..layout.pane_surface.right()) + .find(|&x| !covered.contains((x, y).into())) + .map(|x| (x, y)) + }) + .unwrap(); + let mut surface = surface(); + add_main_image(&mut surface, layout, outside, 1); + add_main_image(&mut surface, layout, inside, 2); + state.set_pane_surface(surface); + let frame = state.compose(cols, rows).unwrap(); + assert!( + is_placed(&frame.graphics, outside), + "outside={outside:?} cover={covered:?}" + ); + assert!( + !is_placed(&frame.graphics, inside), + "inside={inside:?} cover={covered:?}" + ); +} + +#[test] +fn notifications_and_clipboard_feedback_only_cover_their_drawn_corners() { + use crate::config::{ToastClipboardPosition as Clipboard, ToastHerdrPosition as Toast}; + for (cols, rows) in [(106, 40), (40, 24)] { + for position in [ + Toast::TopLeft, + Toast::TopRight, + Toast::BottomLeft, + Toast::BottomRight, + ] { + let mut state = + ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.sidebar_collapsed = true; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "notice".into(), + body: Some("body".into()), + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(position), + }, + deadline: std::time::Instant::now(), + }); + state.compose(cols, rows).unwrap(); + let rect = state.hits.notification_toast; + assert_graphics_cover(&mut state, rect, cols, rows); + } + for position in [ + Clipboard::TopLeft, + Clipboard::TopCenter, + Clipboard::TopRight, + Clipboard::BottomLeft, + Clipboard::BottomCenter, + Clipboard::BottomRight, + ] { + let mut state = + ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.sidebar_collapsed = true; + state.config.clipboard_toast_position = position; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.copy_feedback = Some(crate::app::state::CopyFeedback { + message: "copied".into(), + }); + let layout = state.layout(cols, rows); + let area = if layout.mobile_header.is_empty() { + layout.pane_surface + } else { + Rect::new(0, 0, cols, rows) + }; + let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows)); + let rect = crate::ui::render_copy_feedback_buffer( + &mut buffer, + area, + state.copy_feedback.as_ref().unwrap(), + 0, + position, + &state.config.palette, + ); + assert_graphics_cover(&mut state, rect, cols, rows); + } + } +} + +#[test] +fn endpoint_notice_and_multiline_diagnostic_cover_the_actual_rows() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.visible_endpoint_notice = Some(ClientVisibleEndpointNotice { + key: ClientEndpointNoticeKey { + boot_id: "boot-1".into(), + kind: ClientEndpointNoticeKind::Rejected, + code: "test".into(), + }, + title: "error".into(), + body: "body".into(), + deadline: std::time::Instant::now(), + }); + state.compose(106, 20).unwrap(); + let rect = state.hits.notification_toast; + assert_graphics_cover(&mut state, rect, 106, 20); + state.visible_endpoint_notice = None; + state.config_diagnostic = Some("first\nsecond warning".into()); + assert_graphics_cover(&mut state, Rect::new(90, 1, 16, 1), 106, 20); +} + +#[test] +fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { + let palette = ClientShellConfig::from_config(&Config::default()).palette; + let overlays = vec![ + ClientShellOverlay::Onboarding, + ClientShellOverlay::ProductAnnouncement(crate::app::state::ProductAnnouncementState { + version: "1".into(), + id: "test".into(), + title: "news".into(), + body: "body".into(), + scroll: 0, + preview: false, + }), + ClientShellOverlay::ReleaseNotes(crate::app::state::ReleaseNotesState { + version: "1".into(), + body: "body".into(), + scroll: 0, + preview: false, + }), + ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename", + input: "name".into(), + replace_on_type: false, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + }), + ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { + target: ClientConfirmCloseTarget::Workspace { + workspace_id: "ws_1".into(), + }, + title: "close".into(), + detail: "confirm".into(), + }), + ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + }), + ClientShellOverlay::Navigator(ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: None, + scroll: 0, + filter: None, + expanded_workspaces: HashSet::new(), + }), + ClientShellOverlay::WorktreeCreate(ClientWorktreeCreateOverlay { + source_workspace_id: "ws_1".into(), + repo_name: "repo".into(), + branch: "branch".into(), + checkout_path: "path".into(), + replace_on_type: false, + error: None, + creating: false, + }), + ClientShellOverlay::WorktreeOpen(ClientWorktreeOpenOverlay { + source_workspace_id: "ws_1".into(), + entries: Vec::new(), + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }), + ClientShellOverlay::WorktreeRemove(ClientWorktreeRemoveOverlay { + workspace_id: "ws_1".into(), + path: "path".into(), + error: None, + removing: false, + force_confirmation: false, + }), + ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Tab { + tab_id: "tab_1".into(), + workspace_id: "ws_1".into(), + }, + x: 35, + y: 8, + highlighted: 0, + }), + ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { highlighted: 0 }), + ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + selected: 0, + original_theme_name: String::new(), + original_palette: palette, + integrations: Vec::new(), + integration_messages: Vec::new(), + loading_integrations: false, + installing_integrations: false, + }), + ]; + for overlay in overlays { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 40).unwrap(); + let layout = state.layout(106, 40); + let mut buffer = Buffer::empty(Rect::new(0, 0, 106, 40)); + let snapshot = state.snapshot.as_deref().unwrap(); + let rendered = match &overlay { + ClientShellOverlay::ContextMenu(menu) => { + render::render_context_menu(&mut buffer, menu, &state.config.palette) + } + ClientShellOverlay::GlobalMenu(menu) => render::render_global_menu( + &mut buffer, + state.hits.global_launcher, + menu, + snapshot, + &state.config.palette, + ), + _ => render::render_client_overlay( + &mut buffer, + &overlay, + snapshot, + &state.endpoints, + &state.active_endpoint_id, + &state.config.keybinds, + &state.config.palette, + ), + } + .unwrap(); + let area = rendered.area; + assert!(!area.is_empty(), "{overlay:?}"); + let covered = area.intersection(layout.pane_surface); + let outside = (layout.pane_surface.y..layout.pane_surface.bottom()) + .find_map(|y| { + (layout.pane_surface.x..layout.pane_surface.right()) + .find(|&x| !area.contains((x, y).into())) + .map(|x| (x, y)) + }) + .unwrap(); + let mut surface = surface(); + add_main_image(&mut surface, layout, outside, 1); + let border = (!covered.is_empty()).then(|| (covered.right() - 1, covered.bottom() - 1)); + if let Some(border) = border { + add_main_image(&mut surface, layout, border, 2); + } + state.set_pane_surface(surface); + state.compose(106, 40).unwrap(); + state.overlay = Some(overlay); + let frame = state.compose(106, 40).unwrap(); + assert!(is_placed(&frame.graphics, outside), "{:?}", state.overlay); + if let Some(border) = border { + assert!(!is_placed(&frame.graphics, border), "{:?}", state.overlay); + assert!(String::from_utf8_lossy(&frame.graphics).contains("a=d,d=i")); + } + state.overlay = None; + let restored = state.compose(106, 40).unwrap(); + assert!(is_placed(&restored.graphics, outside)); + if let Some(border) = border { + assert!(is_placed(&restored.graphics, border)); + } + assert!(!String::from_utf8_lossy(&restored.graphics).contains("a=t")); + } +} + +#[test] +fn selection_copy_cursor_and_search_hide_only_the_highlighted_images() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let layout = state.layout(106, 20); + let mut surface = surface(); + surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + let points = [(0, 0), (1, 1), (2, 0), (3, 0)] + .map(|(x, y)| (layout.pane_surface.x + x, layout.pane_surface.y + y)); + for (id, point) in points.iter().enumerate() { + add_main_image(&mut surface, layout, *point, id as u32 + 1); + } + state.set_pane_surface(surface); + state.compose(106, 20).unwrap(); + assert!(state.enter_copy_mode(&mut ClientShellInput::default())); + state.selection = Some(crate::selection::Selection::absolute_range( + "pane_1".into(), + (0, 0), + (0, 0), + )); + state + .copy_mode + .as_mut() + .unwrap() + .search_matches + .push(crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 0, col: 2 }, + end: crate::api::schema::PaneTextPoint { row: 0, col: 2 }, + }); + let frame = state.compose(106, 20).unwrap(); + for point in &points[..3] { + assert!(!is_placed(&frame.graphics, *point), "{point:?}"); + } + assert!(is_placed(&frame.graphics, points[3])); + state.mode = ClientShellMode::Terminal; + state.copy_mode = None; + state.selection = None; + let frame = state.compose(106, 20).unwrap(); + for point in points { + assert!(is_placed(&frame.graphics, point)); + } + assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); +} + +#[test] +fn mobile_switcher_still_hides_the_entire_underlying_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let layout = state.layout(40, 24); + let point = (layout.pane_surface.x, layout.pane_surface.y); + let mut surface = surface(); + add_main_image(&mut surface, layout, point, 1); + state.set_pane_surface(surface); + let frame = state.compose(40, 24).unwrap(); + assert!(is_placed(&frame.graphics, point)); + state.mode = ClientShellMode::Navigate; + let frame = state.compose(40, 24).unwrap(); + assert!(!is_placed(&frame.graphics, point)); + assert!(String::from_utf8_lossy(&frame.graphics).contains("a=d,d=i")); + state.mode = ClientShellMode::Terminal; + let frame = state.compose(40, 24).unwrap(); + assert!(is_placed(&frame.graphics, point)); + assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); +} + +#[test] +fn popup_terminal_keeps_own_graphics_and_hides_only_background_overlap() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.compose(106, 20).unwrap(); + let popup = state.hits.popup.clone().unwrap(); + let layout = state.layout(106, 20); + let outside = (layout.pane_surface.x, layout.pane_surface.y); + let border = (popup.rect.x, popup.rect.y); + let inside = (popup.inner_rect.x, popup.inner_rect.y); + let mut surface = surface_with_popup(); + add_main_image(&mut surface, layout, outside, 1); + add_main_image(&mut surface, layout, border, 2); + let (asset, placement) = image( + SurfaceGraphicsTarget::Popup { + terminal_id: "terminal-popup".into(), + }, + 0, + 0, + 3, + ); + surface.graphics.assets.push(asset); + surface.graphics.placements.push(placement); + state.set_pane_surface(surface); + let frame = state.compose(106, 20).unwrap(); + assert!(is_placed(&frame.graphics, outside)); + assert!(!is_placed(&frame.graphics, border)); + assert!(is_placed(&frame.graphics, inside)); + state.overlay = Some(ClientShellOverlay::Onboarding); + let frame = state.compose(106, 20).unwrap(); + assert!(is_placed(&frame.graphics, outside)); + assert!(!is_placed(&frame.graphics, inside)); + state.overlay = None; + let frame = state.compose(106, 20).unwrap(); + assert!(is_placed(&frame.graphics, inside)); + assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); +} diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index 863c60741b..f06a339581 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -218,8 +218,9 @@ fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility( ))); state.overlay = Some(ClientShellOverlay::Onboarding); - let hidden = state.compose(106, 20).expect("overlay frame"); - assert!(String::from_utf8_lossy(&hidden.graphics).contains("a=d,d=i")); + let uncovered = state.compose(106, 20).expect("overlay frame"); + assert!(!String::from_utf8_lossy(&uncovered.graphics).contains("a=d")); + assert!(String::from_utf8_lossy(&uncovered.graphics).contains("a=p")); state.overlay = None; let restored = state.compose(106, 20).expect("restored graphics frame"); diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index 1654d0dfe8..e1b43a0102 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -164,6 +164,39 @@ fn tab_bar_renders_endpoint_status_ellipses_and_clamps_to_useful_scroll() { assert!(!top.contains("ZOOM · host")); } +#[test] +fn inactive_auto_named_tab_label_does_not_stack_terminal_faint() { + let mut projected = snapshot(); + projected.tabs.push(ClientShellTab { + tab_id: "tab_2".into(), + workspace_id: "ws_1".into(), + number: 2, + label: "beta".into(), + custom_label: false, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("tab bar frame"); + let rect = state + .hits + .tabs + .iter() + .find(|(_, tab_id)| tab_id == "tab_2") + .expect("inactive tab hit") + .0; + let buffer = frame.to_ratatui_buffer().expect("tab bar buffer"); + let (x, y) = cell_symbol_position(&frame, rect, "beta"); + let cell = buffer.cell((x, y)).expect("inactive tab cell"); + assert!( + !cell.modifier.contains(Modifier::DIM), + "inactive tab label at ({x},{y}) should not stack terminal faint: {cell:?}" + ); +} + #[test] fn configured_prefix_is_client_owned_and_renders_its_bar() { let config = toml::from_str::( diff --git a/src/client/shell/tests/link_hover.rs b/src/client/shell/tests/link_hover.rs new file mode 100644 index 0000000000..f29fdec2e0 --- /dev/null +++ b/src/client/shell/tests/link_hover.rs @@ -0,0 +1,410 @@ +use super::*; + +fn hover_state() -> ClientShellState { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.set_pane_surface(surface()); + state.compose(106, 20).unwrap(); + state +} + +fn hover_mouse(state: &ClientShellState, col: u16, row: u16) -> MouseEvent { + let pane = &state.hits.panes[0]; + MouseEvent { + kind: MouseEventKind::Moved, + column: pane.inner_rect.x + col, + row: pane.inner_rect.y + row, + modifiers: KeyModifiers::CONTROL, + } +} + +fn hover_request(outcome: &ClientShellInput) -> String { + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected one side-effect-free link resolution"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::PaneLinkResolve(_) + )); + request.id.clone() +} + +fn resolve_hover(state: &mut ClientShellState, id: &str) -> bool { + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + id, + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { + regions: vec![ + crate::api::schema::PaneLinkRegion { + row: 0, + start_col: 1, + end_col: 3, + }, + crate::api::schema::PaneLinkRegion { + row: 1, + start_col: 0, + end_col: 2, + }, + ], + }), + ); + assert!( + actions.is_empty(), + "hover must never open a URL or activate a plugin" + ); + repaint +} + +#[test] +fn ctrl_hover_underlines_wrapped_segments_and_caches_the_link() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + let frame = state.compose(106, 20).unwrap(); + let pane = &state.hits.panes[0]; + let underlined = |col, row| { + frame.cells[usize::from(pane.inner_rect.y + row) * usize::from(frame.width) + + usize::from(pane.inner_rect.x + col)] + .modifier + & Modifier::UNDERLINED.bits() + != 0 + }; + assert!(!underlined(0, 0)); + assert!(underlined(1, 0)); + assert!(underlined(3, 0)); + assert!(underlined(0, 1)); + assert!(underlined(2, 1)); + assert!(!underlined(3, 1)); + let moved = hover_mouse(&state, 2, 1); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(moved)]); + assert!(outcome.actions.is_empty()); + assert!(!outcome.repaint); + assert!(state.selection.is_none()); +} + +#[test] +fn ctrl_hover_pending_request_does_not_block_another_endpoint_with_the_same_boot_id() { + use crate::client::endpoint::{ + ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint, + }; + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let old_id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + let profile = SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Other connection".into(), + target: "host".into(), + session: "default".into(), + enabled: true, + }; + let endpoint = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&endpoint, Box::new(snapshot())); + assert!(state.activate_endpoint_projection(&endpoint)); + assert!( + state.pending_requests.is_empty(), + "endpoint switches retire old requests" + ); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.set_pane_surface(surface()); + state.compose(106, 20).unwrap(); + let mouse = hover_mouse(&state, 1, 0); + let new_id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!( + !resolve_hover(&mut state, &old_id), + "old endpoint must not paint here" + ); + assert!(resolve_hover(&mut state, &new_id)); +} + +#[test] +fn ctrl_hover_coalesces_motion_and_ignores_old_replies() { + let mut state = hover_state(); + let first = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(first)])); + for col in [2, 3, 0] { + let mouse = hover_mouse(&state, col, 1); + assert!(state + .handle_raw_events(vec![RawInputEvent::Mouse(mouse)]) + .actions + .is_empty()); + } + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &id, + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { regions: vec![] }), + ); + assert!(!repaint); + assert!( + matches!( + &actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneLinkResolve(params) + if params.viewport_row == 1 && params.col == 0) + ), + "only the latest pointer position should be queried" + ); + assert!(state.link_hover.as_ref().unwrap().regions.is_empty()); +} + +#[test] +fn ctrl_hover_clears_on_release_content_change_and_focus_loss() { + for clear in 0..4 { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + match clear { + 0 => { + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + modifiers: KeyModifiers::empty(), + ..mouse + })]); + } + 1 => { + let mut next = surface(); + next.surface_revision += 1; + next.panes[0].content_revision += 2; + state.set_pane_surface(next); + } + 2 => { + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + } + _ => { + state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new( + KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl), + KeyModifiers::empty(), + ) + .with_kind(crossterm::event::KeyEventKind::Release), + )]); + } + } + assert!(state.link_hover.is_none()); + } +} + +#[test] +fn ctrl_hover_content_patch_removes_all_old_underlines() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + state.compose(106, 20).unwrap(); + let mut pane = surface().panes.remove(0); + pane.content_revision = 2; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![surface().frame.cells[0].clone()], + }], + panes: vec![pane], + cursor: None, + }; + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(None) + )); + assert!(state.link_hover.is_none()); + let frame = state.compose(106, 20).unwrap(); + let pane = &state.hits.panes[0]; + for row in 0..2 { + for col in 0..4 { + let idx = usize::from(pane.inner_rect.y + row) * usize::from(frame.width) + + usize::from(pane.inner_rect.x + col); + assert_eq!(frame.cells[idx].modifier & Modifier::UNDERLINED.bits(), 0); + } + } +} + +#[test] +fn ctrl_hover_preserves_fast_patches_for_other_panes() { + let mut state = hover_state(); + let mut next = surface(); + next.surface_revision = 2; + next.frame.height = 4; + next.frame.cells.extend(next.frame.cells.clone()); + let mut other = next.panes[0].clone(); + other.pane_id = "pane_2".into(); + other.rect.y = 2; + other.inner_rect.y = 2; + next.panes.push(other.clone()); + state.set_pane_surface(next); + state.compose(106, 20).unwrap(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + state.compose(106, 20).unwrap(); + other.content_revision = 2; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 2, + surface_revision: 3, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 2, + cells: vec![surface().frame.cells[0].clone()], + }], + panes: vec![other], + cursor: None, + }; + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(Some(_)) + )); + assert!(!state.link_hover.as_ref().unwrap().regions.is_empty()); +} + +#[test] +fn ctrl_hover_ignores_late_reply_after_pointer_leaves() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + column: 0, + row: 0, + ..mouse + })]); + assert!(!resolve_hover(&mut state, &id)); + assert!(state.link_hover.is_none()); +} + +#[test] +fn ctrl_hover_is_silent_when_unsupported_or_rejected() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mouse = hover_mouse(&state, 1, 0); + assert!(state + .handle_raw_events(vec![RawInputEvent::Mouse(mouse)]) + .actions + .is_empty()); + assert!(state.visible_endpoint_notice.is_none()); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.clear_link_hover(); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + state.handle_endpoint_result( + "boot-1", + &id, + Err(ClientShellEndpointError { + code: Some("endpoint_timeout".into()), + message: "timeout".into(), + }), + ); + assert!(state.visible_endpoint_notice.is_none()); +} + +#[test] +fn ctrl_hover_explicit_wide_label_includes_the_spacer_cell() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mut next = surface(); + next.surface_revision += 1; + next.frame.hyperlinks.push("https://example.com/".into()); + next.frame.cells[1].symbol = "界".into(); + next.frame.cells[1].hyperlink = Some(0); + next.frame.cells[2].symbol = "".into(); + state.set_pane_surface(next); + let mouse = hover_mouse(&state, 2, 0); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + let regions = &state.link_hover.as_ref().unwrap().regions; + assert_eq!(regions.len(), 1); + assert_eq!((regions[0].start_col, regions[0].end_col), (1, 2)); +} + +#[test] +#[ignore = "non-gating fixed-geometry hover composition profile"] +fn ctrl_hover_render_scale_profile() { + for count in [1, 15] { + let mut state = hover_state(); + let mut next = surface(); + next.surface_revision += 1; + next.frame = + FrameData::from_ratatui_buffer(&Buffer::with_lines(vec!["x".repeat(120); 30]), None); + let template = next.panes[0].clone(); + next.panes.clear(); + for index in 0..count { + let mut pane = template.clone(); + pane.pane_id = format!("pane_{index}"); + pane.inner_rect = SurfaceRect { + x: if count == 1 { 0 } else { (index % 5) * 24 }, + y: if count == 1 { 0 } else { (index / 5) * 10 }, + width: if count == 1 { 120 } else { 24 }, + height: if count == 1 { 30 } else { 10 }, + }; + pane.rect = pane.inner_rect; + next.panes.push(pane); + } + state.set_pane_surface(next); + state.compose(146, 32).unwrap(); + for active in [false, true] { + if active { + let pane = state.hits.panes.last().unwrap(); + let mouse = MouseEvent { + kind: MouseEventKind::Moved, + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::CONTROL, + }; + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + } + let mut samples = Vec::new(); + for _ in 0..200 { + let started = std::time::Instant::now(); + std::hint::black_box(state.compose(146, 32).unwrap()); + samples.push(started.elapsed().as_micros()); + } + samples.sort_unstable(); + eprintln!( + "ctrl-hover panes={count} active={active} median_us={} p95_us={}", + samples[100], samples[190] + ); + assert!( + state.pending_requests.is_empty(), + "composition must never resolve links" + ); + } + } +} + +#[test] +fn ctrl_hover_groups_contiguous_same_destination_cells_without_server_query() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mut next = surface(); + next.surface_revision += 1; + next.frame.hyperlinks.push("https://example.com/".into()); + for idx in 1..7 { + next.frame.cells[idx].hyperlink = Some(0); + } + state.set_pane_surface(next); + let mouse = hover_mouse(&state, 1, 0); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + assert_eq!(state.link_hover.as_ref().unwrap().regions.len(), 2); + + let mut next = state.pane_surface.as_ref().unwrap().clone(); + next.surface_revision += 1; + next.panes[0].content_revision += 2; + next.frame.cells[3].hyperlink = None; + state.set_pane_surface(next); + state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + let regions = &state.link_hover.as_ref().unwrap().regions; + assert_eq!(regions.len(), 1, "an unlinked gap must stop grouping"); + assert_eq!( + (regions[0].row, regions[0].start_col, regions[0].end_col), + (0, 1, 2) + ); +} diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 497ce2c673..b6b600bc1e 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -133,6 +133,41 @@ fn surface() -> PaneSurfaceFrame { } } +fn frame_rows(frame: &FrameData) -> Vec { + frame + .cells + .chunks(frame.width as usize) + .map(|row| row.iter().map(|cell| cell.symbol.as_str()).collect()) + .collect() +} + +/// Absolute cell position of `needle` inside `area`, for style assertions. +fn cell_symbol_position(frame: &FrameData, area: Rect, needle: &str) -> (u16, u16) { + let rows = frame_rows(frame); + for y in area.y..area.bottom().min(frame.height) { + let row = &rows[y as usize]; + let slice = row + .chars() + .skip(area.x as usize) + .take(area.width as usize) + .collect::(); + if let Some(byte) = slice.find(needle) { + let column = slice[..byte].chars().count() as u16 + area.x; + return (column, y); + } + } + let visible = (area.y..area.bottom().min(frame.height)) + .map(|y| { + rows[y as usize] + .chars() + .skip(area.x as usize) + .take(area.width as usize) + .collect::() + }) + .collect::>(); + panic!("symbol {needle:?} not found in {area:?}: {visible:?}"); +} + fn pane_scroll_result( offset_from_bottom: u64, max_offset_from_bottom: u64, @@ -213,9 +248,11 @@ mod chrome_context; mod copy; mod endpoint_requests; mod endpoints; +mod graphics; #[path = "input.rs"] mod input_domain; mod keybindings_settings; +mod link_hover; mod mobile; mod mouse_selection; mod popup_focus_projection; diff --git a/src/client/shell/word_selection.rs b/src/client/shell/word_selection.rs index 42a03fc26c..f4329cedba 100644 --- a/src/client/shell/word_selection.rs +++ b/src/client/shell/word_selection.rs @@ -189,8 +189,9 @@ impl ClientShellState { other => { if matches!(other, Ok(value) if !matches!(value, crate::api::schema::ResponseResult::PaneSelection { .. })) { - self.endpoint_error = - Some("endpoint returned an unexpected word-selection result".to_owned()); + self.set_endpoint_error( + "endpoint returned an unexpected word-selection result", + ); } self.cancel_word_selection(); return (true, Vec::new()); diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs index a2b5c65f03..f4b9d61149 100644 --- a/src/client/shell/worktree_overlays.rs +++ b/src/client/shell/worktree_overlays.rs @@ -94,6 +94,7 @@ pub(super) fn render_worktree_create_overlay( .add_modifier(Modifier::BOLD), ); Some(OverlayRender { + area: popup, primary: *primary, clear: Rect::default(), cancel: *cancel, @@ -280,6 +281,7 @@ pub(super) fn render_worktree_open_overlay( .add_modifier(Modifier::BOLD), ); Some(OverlayRender { + area: popup, primary: *primary, clear: Rect::default(), cancel: *cancel, @@ -396,6 +398,7 @@ pub(super) fn render_worktree_remove_overlay( .add_modifier(Modifier::BOLD), ); Some(OverlayRender { + area: popup, primary: *primary, clear: Rect::default(), cancel: *cancel, diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index c50b86bed2..d425367268 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -213,9 +213,8 @@ impl ClientShellState { .is_some_and(|worktree| worktree.is_linked_worktree); let kind = match action { KeybindAction::NewWorktree | KeybindAction::OpenWorktree if linked => { - self.endpoint_error = Some( - "New and open worktree actions start from the repo parent workspace." - .to_owned(), + self.set_endpoint_error( + "New and open worktree actions start from the repo parent workspace.", ); outcome.repaint = true; return; @@ -227,8 +226,7 @@ impl ClientShellState { workspace_id: workspace_id.clone(), }, KeybindAction::RemoveWorktree if !linked => { - self.endpoint_error = - Some("This workspace is not a Herdr-managed worktree checkout.".to_owned()); + self.set_endpoint_error("This workspace is not a Herdr-managed worktree checkout."); outcome.repaint = true; return; } @@ -442,7 +440,7 @@ impl ClientShellState { }) .collect::>(); if entries.is_empty() { - self.endpoint_error = Some("No Git worktrees found for this repo.".to_owned()); + self.set_endpoint_error("No Git worktrees found for this repo."); } else { self.overlay = Some(ClientShellOverlay::WorktreeOpen( ClientWorktreeOpenOverlay { @@ -477,8 +475,9 @@ impl ClientShellState { }, )); } else { - self.endpoint_error = - Some("This workspace is not a Herdr-managed worktree checkout.".to_owned()); + self.set_endpoint_error( + "This workspace is not a Herdr-managed worktree checkout.", + ); } true } @@ -543,8 +542,7 @@ impl ClientShellState { Err(_), ) => true, (_, Ok(_)) => { - self.endpoint_error = - Some("endpoint returned an unexpected worktree result".to_owned()); + self.set_endpoint_error("endpoint returned an unexpected worktree result"); true } ( @@ -559,6 +557,7 @@ impl ClientShellState { | PendingEndpointKind::PaneScroll { .. } | PendingEndpointKind::WordSelection { .. } | PendingEndpointKind::PaneLinkActivate { .. } + | PendingEndpointKind::PaneLinkResolve { .. } | PendingEndpointKind::CopyMotion { .. } | PendingEndpointKind::CopySearch { .. }, Err(_), diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 9c035346b5..7441f6db49 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -265,16 +265,46 @@ pub(super) fn effective_sgr_pixel_mouse( enabled && requested && exact_geometry } +#[cfg(any(windows, test))] +fn set_windows_native_mouse_capture( + writer: &mut W, + enabled: bool, + sgr_pixels: bool, + set_console_capture: impl FnOnce(bool) -> io::Result<()>, +) -> io::Result<()> { + crate::terminal_modes::clear_host_mouse_reporting(writer)?; + set_console_capture(enabled)?; + if enabled { + crate::terminal_modes::set_windows_mouse_reporting(writer, true, sgr_pixels)?; + } + Ok(()) +} + pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; #[cfg(windows)] if is_ssh_session() && windows_vti_input_backend_enabled() { - return crate::terminal_modes::set_windows_ssh_mouse_reporting( + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + return crate::terminal_modes::set_windows_mouse_reporting( &mut io::stdout(), enabled, sgr_pixels, ); } + #[cfg(windows)] + return set_windows_native_mouse_capture(&mut io::stdout(), enabled, sgr_pixels, |enabled| { + if enabled { + execute!(io::stdout(), EnableMouseCapture) + } else { + match execute!(io::stdout(), DisableMouseCapture) { + Ok(()) => Ok(()), + Err(err) if err.to_string() == "Initial console modes not set" => Ok(()), + Err(err) => Err(err), + } + } + }); + #[cfg(not(windows))] + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + #[cfg(not(windows))] if enabled { execute!(io::stdout(), EnableMouseCapture)?; if sgr_pixels { @@ -285,8 +315,6 @@ pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<( } else { match execute!(io::stdout(), DisableMouseCapture) { Ok(()) => Ok(()), - #[cfg(windows)] - Err(err) if err.to_string() == "Initial console modes not set" => Ok(()), Err(err) => Err(err), } } @@ -443,3 +471,24 @@ impl Drop for TerminalGuard { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_native_mouse_capture_restores_reporting_after_reset() { + let mut output = Vec::new(); + + set_windows_native_mouse_capture(&mut output, true, false, |enabled| { + assert!(enabled); + Ok(()) + }) + .unwrap(); + + assert_eq!( + output, + b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016l" + ); + } +} diff --git a/src/config/model.rs b/src/config/model.rs index aca18911ce..ff006325fc 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1080,7 +1080,7 @@ pub struct ExperimentalConfig { /// if the list contains no valid names, the reveal does not apply. /// Accepted names: pi, claude, codex, gemini, cursor, devin, cline, /// opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, - /// qodercli, qoder, qwen, qwen-code, maki. + /// qodercli, qoder, qwen, qwen-code, letta, letta-code, maki. /// Default: empty. pub cjk_ime_agents: Vec, /// Cursor shape rendered for the IME anchor when diff --git a/src/config/sound.rs b/src/config/sound.rs index 2215c832bd..8eb2561ee2 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -44,6 +44,7 @@ pub struct AgentSoundOverrides { pub kilo: AgentSoundSetting, pub qodercli: AgentSoundSetting, pub qwen: AgentSoundSetting, + pub letta: AgentSoundSetting, pub maki: AgentSoundSetting, pub muse: AgentSoundSetting, } @@ -142,6 +143,7 @@ impl AgentSoundOverrides { Some(Agent::Kilo) => self.kilo, Some(Agent::Qodercli) => self.qodercli, Some(Agent::Qwen) => self.qwen, + Some(Agent::Letta) => self.letta, Some(Agent::Maki) => self.maki, Some(Agent::Muse) => self.muse, None => AgentSoundSetting::Default, @@ -183,6 +185,7 @@ impl Default for AgentSoundOverrides { kilo: AgentSoundSetting::Default, qodercli: AgentSoundSetting::Default, qwen: AgentSoundSetting::Default, + letta: AgentSoundSetting::Default, maki: AgentSoundSetting::Default, muse: AgentSoundSetting::Default, } @@ -223,6 +226,17 @@ claude = "on" assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off); assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); + assert_eq!(config.ui.sound.agents.letta, AgentSoundSetting::Default); + } + + #[test] + fn letta_sound_override_is_used() { + let overrides: AgentSoundOverrides = toml::from_str("letta = \"on\"").unwrap(); + assert_eq!(overrides.letta, AgentSoundSetting::On); + assert_eq!( + overrides.for_agent(Some(Agent::Letta)), + AgentSoundSetting::On + ); } #[test] diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index 4ef77d2a28..1ca532286c 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -251,6 +251,7 @@ const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ ("kilo", include_str!("manifests/kilo.toml")), ("kimi", include_str!("manifests/kimi.toml")), ("kiro", include_str!("manifests/kiro.toml")), + ("letta", include_str!("manifests/letta.toml")), ("maki", include_str!("manifests/maki.toml")), ("muse", include_str!("manifests/muse.toml")), ("opencode", include_str!("manifests/opencode.toml")), diff --git a/src/detect/manifests/letta.toml b/src/detect/manifests/letta.toml new file mode 100644 index 0000000000..e27e2aa722 --- /dev/null +++ b/src/detect/manifests/letta.toml @@ -0,0 +1,95 @@ +id = "letta" +version = "2026.08.24.1" +min_engine_version = 3 +updated_at = "2026-08-24T00:00:00Z" +aliases = ["letta-code", "letta code"] + +# Letta keeps completed tool rows and spinners in the transcript. A standalone +# screen spinner is not working evidence. Prefer its live OSC title/progress +# signals, with visible status chrome and running tool rows as fallbacks. +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1400 +region = "osc_progress" +visible_blocker = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +regex = ['^\[ [!.] \] Action Required(?: \| |$)'] + +[[rules]] +id = "command_approval" +state = "blocked" +priority = 1200 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Run this command?", "Enter to select · Esc to cancel"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "active_status" +state = "working" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] + +[[rules]] +id = "running_tool" +state = "working" +priority = 800 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] + +[[rules]] +id = "profile_selector" +state = "unknown" +priority = 700 +region = "bottom_non_empty_lines(12)" +contains = ["Create a new agent (--new)", "Enter select · Esc exit"] + +[[rules]] +id = "composer_input" +state = "unknown" +priority = 150 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*›\s+\S.*$'] +not = [ + { line_regex = ['^\s*›\s+Try\s+"'] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(8)" +visible_idle = true +any = [ + { line_regex = ['^\s*›\s*$'] }, + { line_regex = ['^\s*›\s+Try\s+"'] }, +] +not = [ + { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] }, + { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] }, +] + +[[rules]] +id = "no_live_state_evidence" +state = "unknown" +priority = 0 +region = "whole_recent" +regex = ['(?s)^.*$'] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 0248d5cedb..0c773f0ec3 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -62,12 +62,13 @@ pub enum Agent { Kilo, Qodercli, Qwen, + Letta, Maki, Muse, } impl Agent { - pub const ALL: [Self; 23] = [ + pub const ALL: [Self; 24] = [ Self::Pi, Self::Claude, Self::Codex, @@ -89,11 +90,12 @@ impl Agent { Self::Kilo, Self::Qodercli, Self::Qwen, + Self::Letta, Self::Maki, Self::Muse, ]; - pub const SCREEN_MANIFEST_AGENTS: [Self; 21] = [ + pub const SCREEN_MANIFEST_AGENTS: [Self; 22] = [ Self::Pi, Self::Claude, Self::Codex, @@ -113,6 +115,7 @@ impl Agent { Self::Kilo, Self::Qodercli, Self::Qwen, + Self::Letta, Self::Maki, Self::Muse, ]; @@ -141,6 +144,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", Agent::Qwen => "qwen", + Agent::Letta => "letta", Agent::Maki => "maki", Agent::Muse => "muse", } @@ -175,6 +179,7 @@ pub fn interactive_agent_executable(agent: Agent) -> &'static str { Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", Agent::Qwen => "qwen", + Agent::Letta => "letta", Agent::Maki => "maki", Agent::Muse => "muse", } @@ -214,6 +219,7 @@ fn lookup_agent(name: &str) -> Option { "kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo), "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), "qwen" | "qwen-code" | "qwen code" => Some(Agent::Qwen), + "letta" | "letta-code" | "letta code" => Some(Agent::Letta), "maki" => Some(Agent::Maki), "muse" | "muse-code" | "muse-cli" => Some(Agent::Muse), _ if is_muse_versioned_binary(name) => Some(Agent::Muse), @@ -248,7 +254,9 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag { let candidate = normalized_process_name(process); if let Some(agent) = identify_agent(&candidate) { - return Some((agent, candidate)); + if agent != Agent::Letta || is_interactive_letta_process(process) { + return Some((agent, candidate)); + } } } @@ -259,6 +267,9 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag let Some(agent) = identify_agent(&candidate) else { continue; }; + if agent == Agent::Letta && !is_interactive_letta_process(process) { + continue; + } let score = process_priority(process, &candidate); match &best { @@ -328,7 +339,10 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> pub(crate) fn session_identity_only_integration(source: &str, agent_label: &str) -> bool { matches!( (source, agent_label), - ("herdr:hermes", "hermes") | ("herdr:qwen", "qwen") | ("herdr:antigravity_cli", "agy") + ("herdr:hermes", "hermes") + | ("herdr:qwen", "qwen") + | ("herdr:letta", "letta") + | ("herdr:antigravity_cli", "agy") ) } @@ -380,7 +394,7 @@ fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> Stri { if matches!( identify_agent(&wrapped_agent), - Some(Agent::Qwen | Agent::Cline) + Some(Agent::Qwen | Agent::Cline | Agent::Letta) ) { return wrapped_agent; } @@ -634,6 +648,15 @@ fn agent_name_from_known_package_path(path: &str) -> Option { ]) { return Some(agent_label(Agent::Pi).to_string()); } + if ends_with(&[ + "node_modules", + "@moonshot-ai", + "kimi-code", + "dist", + "main.mjs", + ]) { + return Some(agent_label(Agent::Kimi).to_string()); + } let components: Vec = raw_components .into_iter() @@ -648,10 +671,121 @@ fn agent_name_from_known_package_path(path: &str) -> Option { if window == ["node_modules", "mastracode", "dist", "cli"] { return Some(agent_label(Agent::Mastracode).to_string()); } + if window == ["node_modules", "@letta-ai", "letta-code", "letta"] { + return Some(agent_label(Agent::Letta).to_string()); + } + } + None +} + +fn letta_entrypoint_index(argv: &[String]) -> Option { + let is_letta = + |arg: &str| agent_name_from_path_token(arg).as_deref() == Some(agent_label(Agent::Letta)); + if argv.first().is_some_and(|arg| is_letta(arg)) { + return Some(0); + } + + let runtime = argv + .first() + .map(|arg| normalized_agent_lookup_name(path_basename(arg)))?; + if !matches!(runtime.as_str(), "node" | "bun") { + return None; + } + + let mut index = 1; + while let Some(arg) = argv.get(index) { + if arg == "--" { + return argv + .get(index + 1) + .is_some_and(|arg| is_letta(arg)) + .then_some(index + 1); + } + if flag_matches(arg, &["-e", "--eval", "-p", "--print"]) { + return None; + } + if arg.starts_with('-') { + index += if option_takes_value(arg) { 2 } else { 1 }; + continue; + } + return is_letta(arg).then_some(index); + } + None +} + +fn letta_first_arg_after_backend_selection(args: &[String]) -> Option<&str> { + let mut args = args.iter(); + while let Some(arg) = args.next() { + if arg == "--backend" { + let _ = args.next(); + continue; + } + if arg.starts_with("--backend=") { + continue; + } + return Some(arg); } None } +fn is_interactive_letta_process(process: &crate::platform::ForegroundProcess) -> bool { + let parsed_cmdline; + let argv = if let Some(argv) = process.argv.as_deref() { + argv + } else { + parsed_cmdline = process + .cmdline + .as_deref() + .unwrap_or_default() + .split_whitespace() + .map(|arg| arg.trim_matches(|ch| matches!(ch, '\'' | '"')).to_string()) + .collect::>(); + if parsed_cmdline.is_empty() { + return true; + } + &parsed_cmdline + }; + + let cli_args = letta_entrypoint_index(argv) + .map(|index| &argv[index + 1..]) + .unwrap_or(argv); + + if cli_args.iter().any(|arg| { + let option = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name); + matches!( + option, + "-p" | "--print" + | "--prompt" + | "--json" + | "--stream-json" + | "--run" + | "--disable-memory-guard" + | "--output-format" + | "--input-format" + | "--include-partial-messages" + | "--from-agent" + | "--environment" + | "--env" + | "--pre-load-skills" + | "--tags" + | "--ephemeral" + | "--stateless" + | "--max-turns" + | "--memfs-startup" + | "-h" + | "--help" + | "-v" + | "--version" + | "--info" + | "--update" + | "--upgrade" + ) + }) { + return false; + } + + letta_first_arg_after_backend_selection(cli_args).is_none_or(|arg| arg.starts_with('-')) +} + fn resolved_agent_name_from_path_token(token: &str) -> Option { let path = std::path::Path::new(token); if path.components().count() < 2 { @@ -804,6 +938,8 @@ mod tests { assert_eq!(identify_agent("kilo-code"), Some(Agent::Kilo)); assert_eq!(identify_agent("qwen"), Some(Agent::Qwen)); assert_eq!(identify_agent("Qwen Code"), Some(Agent::Qwen)); + assert_eq!(identify_agent("letta"), Some(Agent::Letta)); + assert_eq!(identify_agent("Letta Code"), Some(Agent::Letta)); assert_eq!(identify_agent("maki"), Some(Agent::Maki)); assert_eq!(identify_agent("muse"), Some(Agent::Muse)); assert_eq!(identify_agent("muse-code"), Some(Agent::Muse)); @@ -843,6 +979,7 @@ mod tests { assert_eq!(parse_agent_label("grok-build"), Some(Agent::Grok)); assert_eq!(parse_agent_label("hermes-agent"), Some(Agent::Hermes)); assert_eq!(parse_agent_label("qwen-code"), Some(Agent::Qwen)); + assert_eq!(parse_agent_label("letta-code"), Some(Agent::Letta)); assert_eq!(parse_agent_label("maki"), Some(Agent::Maki)); assert_eq!(parse_agent_label("kilo-code"), Some(Agent::Kilo)); } @@ -887,6 +1024,7 @@ mod tests { (Agent::Kilo, "kilo"), (Agent::Qodercli, "qodercli"), (Agent::Qwen, "qwen"), + (Agent::Letta, "letta"), (Agent::Maki, "maki"), (Agent::Muse, "muse"), ]; @@ -918,6 +1056,7 @@ mod tests { for (source, label, agent) in [ ("herdr:hermes", "hermes", Agent::Hermes), ("herdr:qwen", "qwen", Agent::Qwen), + ("herdr:letta", "letta", Agent::Letta), ("herdr:antigravity_cli", "agy", Agent::Antigravity), ] { assert!(!full_lifecycle_hook_authority(source, label)); @@ -1065,6 +1204,175 @@ mod tests { assert_eq!(identify_agent("MainThread"), None); } + #[test] + fn identify_agent_in_job_detects_interactive_letta_entrypoints() { + for argv in [ + vec!["letta", "--backend", "local"], + vec![ + "node", + "/home/user/project/node_modules/.bin/letta", + "--conversation", + "conversation-id", + ], + vec![ + "node.exe", + r"C:\Users\user\AppData\Roaming\npm\node_modules\@letta-ai\letta-code\letta.js", + "--agent", + "agent-id", + ], + ] { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(123, "MainThread", &argv)], + }; + + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Letta, "letta".to_string())) + ); + } + } + + #[test] + fn identify_agent_in_job_ignores_noninteractive_letta_processes() { + for args in [ + vec!["--prompt", "hello"], + vec!["--output-format", "json"], + vec!["--input-format=stream-json"], + vec!["--ephemeral"], + vec!["--max-turns=1"], + vec!["server"], + vec!["--backend", "local", "server"], + vec!["fix this bug"], + vec!["agents", "list"], + vec!["version"], + ] { + let mut argv = vec!["node", "/home/user/project/node_modules/.bin/letta"]; + argv.extend(args); + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(123, "MainThread", &argv)], + }; + + assert_eq!(identify_agent_in_job(&job), None, "argv: {argv:?}"); + } + + let unrelated = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node", + &["node", "/tmp/server.js", "letta"], + )], + }; + assert_eq!(identify_agent_in_job(&unrelated), None); + + let source_checkout = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node", + &["node", "/home/user/src/letta-code/letta/build.js"], + )], + }; + assert_eq!(identify_agent_in_job(&source_checkout), None); + } + + #[test] + fn letta_manifest_detects_observed_working_and_idle_chrome() { + let empty = manifest::explain(Agent::Letta, ""); + assert_eq!(empty.state, AgentState::Unknown); + assert_eq!( + empty.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("no_live_state_evidence") + ); + for screen in [ + "✻ Thinking…\nTutor is reflecting… (esc to interrupt · 2m 3s)", + "My Tutor is thinking about thinking… (esc to interrupt · 3s)", + "Tutor is calibrating… (interrupting)", + "• Run Compile the integration\n└ Running... (1s)", + ] { + assert_eq!( + detect_state(Some(Agent::Letta), screen), + AgentState::Working + ); + } + assert_eq!( + detect_state( + Some(Agent::Letta), + "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · No model selected" + ), + AgentState::Idle + ); + assert_eq!( + detect_state( + Some(Agent::Letta), + "────────────────\n› explain this code\n────────────────\nTutor · No model selected" + ), + AgentState::Unknown + ); + let selector = manifest::explain( + Agent::Letta, + "8 pinned agents available.\n\n> Resume Bob (pinned)\n View all 8 profiles\n Create a new agent (--new)\n\n ↑↓ navigate · Enter select · Esc exit", + ); + assert_eq!(selector.state, AgentState::Unknown); + assert_eq!( + selector.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("profile_selector") + ); + } + + #[test] + fn letta_manifest_uses_osc_activity_and_approval_signals() { + let idle_screen = + "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · GPT-5.5"; + + for title in ["⠋ Tutor", "project | ⠏ Tutor"] { + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + } + + for title in [ + "[ ! ] Action Required | Tutor", + "[ . ] Action Required | Tutor", + ] { + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, "Tutor", "4;3;0"); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] + fn letta_manifest_detects_observed_command_approval() { + let approval = r#"✻ Thinking… + +──────────────────────────────────────────────────────────────── +Run this command? + + $ rm -f /var/tmp/herdr-blocked-capture-never-created + +❯ 1. Yes + 2. No, and tell Letta Code what to do differently + +Enter to select · Esc to cancel"#; + assert_eq!( + detect_state(Some(Agent::Letta), approval), + AgentState::Blocked + ); + assert_eq!( + detect_state( + Some(Agent::Letta), + "The user asked: Run this command?\n›\n────\nTutor · No model selected" + ), + AgentState::Idle + ); + } + #[test] fn identify_agent_in_job_detects_windows_cursor_install() { let job = crate::platform::ForegroundJob { @@ -1303,6 +1611,26 @@ mod tests { ); } + #[test] + fn identify_agent_in_job_detects_node_wrapped_kimi_package_cli() { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node.exe", + &[ + r"C:\Program Files\nodejs\node.exe", + r"C:\repro-3317-kimi-prefix\node_modules\@moonshot-ai\kimi-code\dist\main.mjs", + ], + )], + }; + + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Kimi, "kimi".to_string())) + ); + } + #[test] fn identify_agent_in_job_ignores_non_cli_pi_package_scripts() { for script in [ diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index 49a06ee57f..bfa7182538 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -1775,6 +1775,15 @@ unsafe extern "C" { out_selection: *mut GhosttySelection, ) -> GhosttyResult; } +unsafe extern "C" { + #[doc = " Derive a word selection with a shared forward/backward cell inspection limit.\n\n Like ghostty_terminal_select_word(), but returns GHOSTTY_NO_VALUE if\n max_cells is zero or the search exhausts that budget. No partial selection\n is returned. The existing options structure and unbounded API are unchanged.\n\n @ingroup selection"] + pub fn ghostty_terminal_select_word_bounded( + terminal: GhosttyTerminal, + options: *const GhosttyTerminalSelectWordOptions, + max_cells: usize, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} unsafe extern "C" { #[doc = " Derive the nearest word selection snapshot between two terminal grid refs.\n\n Starting at options->start, this searches toward options->end (inclusive)\n and returns the first selectable word found using Ghostty's word-selection\n rules.\n\n This is useful for implementing double-click-and-drag selection in a UI. If\n a user double-clicks one word and drags across spaces or punctuation toward\n another word, selecting only the word directly under the current pointer can\n flicker or collapse when the pointer is between words. Instead, ask for the\n nearest word between the original click and the drag point, ask again in the\n reverse direction, and combine the two word bounds into the drag selection.\n\n @snippet c-vt-selection/src/main.c selection-word-between\n\n The returned selection is not installed as the terminal's current\n selection. It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param options Word-between-selection options\n @param[out] out_selection On success, receives the derived selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no\n selectable word content between the valid refs, or\n GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint\n pointer, or output pointer are invalid.\n\n @ingroup selection"] pub fn ghostty_terminal_select_word_between( diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 9ec9d324c1..d1748aad30 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -806,6 +806,12 @@ pub fn encode_focus(event: FocusEvent) -> Result, Error> { Ok(buffer) } +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum LinkTarget { + Uri(String), + Text { text: String, clicked_byte: usize }, +} + pub struct Terminal { raw: ffi::GhosttyTerminal, max_scrollback: usize, @@ -1271,6 +1277,187 @@ impl Terminal { grid_ref_hyperlink_uri(&grid_ref) } + pub(crate) fn viewport_link_target(&self, x: u16, y: u32) -> Result, Error> { + Ok(self + .viewport_link_selection(x, y)? + .map(|(target, _)| target)) + } + + fn viewport_link_selection( + &self, + x: u16, + y: u32, + ) -> Result)>, Error> { + let mut clicked = self.grid_ref(ghostty_viewport_point(x, y))?; + if grid_ref_wide(&clicked)? == CellWide::SpacerTail { + clicked.x = clicked.x.saturating_sub(1); + } + if let Some(uri) = grid_ref_hyperlink_uri(&clicked)? { + return Ok(Some((LinkTarget::Uri(uri), None))); + } + let graphemes = grid_ref_graphemes(&clicked)?; + if graphemes + .first() + .copied() + .and_then(char::from_u32) + .is_none_or(char::is_whitespace) + { + return Ok(None); + } + // Unicode White_Space, matching Rust's char::is_whitespace URL boundaries. + const BOUNDARIES: &[u32] = &[ + 9, 10, 11, 12, 13, 32, 0x85, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, + 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, + ]; + let options = ffi::GhosttyTerminalSelectWordOptions { + size: mem::size_of::(), + ref_: clicked, + boundary_codepoints: BOUNDARIES.as_ptr(), + boundary_codepoints_len: BOUNDARIES.len(), + }; + let mut selection = ffi::GhosttySelection::default(); + // Bound work before formatting, even for an unbroken scrollback-sized token. + let result = unsafe { + ffi::ghostty_terminal_select_word_bounded(self.raw, &options, 8192, &mut selection) + }; + if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE { + return Ok(None); + } + result.into_result()?; + let text = self.format_selection(&selection, FormatterFormat::Plain, true, false)?; + let prefix = self.format_selection( + &ffi::GhosttySelection { + end: clicked, + ..selection + }, + FormatterFormat::Plain, + true, + false, + )?; + let clicked_len: usize = graphemes + .into_iter() + .filter_map(char::from_u32) + .map(char::len_utf8) + .sum(); + Ok(Some(( + LinkTarget::Text { + text, + clicked_byte: prefix.len().saturating_sub(clicked_len), + }, + Some(selection), + ))) + } + + /// Resolve only the bounded plain-text token. OSC 8 regions are resolved by + /// clients from frame hyperlink IDs; their full URI activation path is unchanged. + pub(crate) fn viewport_link_regions( + &self, + x: u16, + y: u32, + resolve: fn(&str, usize) -> Option>, + ) -> Result, Error> { + let cols = self.cols()?; + let rows = self.rows()?; + if x >= cols || y >= u32::from(rows) { + return Ok(Vec::new()); + } + let Some((LinkTarget::Text { text, clicked_byte }, Some(selection))) = + self.viewport_link_selection(x, y)? + else { + return Ok(Vec::new()); + }; + let Some(range) = resolve(&text, clicked_byte) else { + return Ok(Vec::new()); + }; + let point = + |grid_ref: &ffi::GhosttyGridRef, clipped: (u16, u32)| -> Result<(u16, u32), Error> { + let mut out = ffi::GhosttyPointCoordinate::default(); + let result = unsafe { + ffi::ghostty_terminal_point_from_grid_ref( + self.raw, + grid_ref, + ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_VIEWPORT, + &mut out, + ) + }; + if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE { + return Ok(clipped); + } + result.into_result()?; + // C viewport coordinates can extend below the visible viewport + // when scrolled up. Clip those as well as NO_VALUE endpoints. + if out.y >= u32::from(rows) { + return Ok(clipped); + } + Ok((out.x, out.y)) + }; + // The selected token contains the visible hovered cell, so an invisible + // start/end necessarily lies above/below the viewport respectively. + let (start_col, start_row) = point(&selection.start, (0, 0))?; + let (end_col, end_row) = point(&selection.end, (cols - 1, u32::from(rows - 1)))?; + let first = self.grid_ref(ghostty_viewport_point(start_col, start_row))?; + let cell_len = |cell: &ffi::GhosttyGridRef| -> Result { + if matches!( + grid_ref_wide(cell)?, + CellWide::SpacerHead | CellWide::SpacerTail + ) { + return Ok(0); + } + Ok(grid_ref_graphemes(cell)? + .into_iter() + .filter_map(char::from_u32) + .map(char::len_utf8) + .sum()) + }; + // Format a prefix once, not once per cell. Then walk only the selected + // visible cells, retaining one page/grid lookup per row. + let prefix = self.format_selection( + &ffi::GhosttySelection { + end: first, + ..selection + }, + FormatterFormat::Plain, + true, + false, + )?; + let mut byte = prefix.len().saturating_sub(cell_len(&first)?); + let mut regions: Vec = Vec::new(); + for row in start_row..=end_row { + let mut cell = self.grid_ref(ghostty_viewport_point(0, row))?; + let left = if row == start_row { start_col } else { 0 }; + let right = if row == end_row { end_col } else { cols - 1 }; + for col in left..=right { + cell.x = col; + let wide = grid_ref_wide(&cell)?; + if matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail) { + continue; + } + let len = cell_len(&cell)?; + if byte < range.end && byte + len > range.start { + let end = if wide == CellWide::Wide { + (col + 1).min(cols - 1) + } else { + col + }; + if let Some(last) = regions + .last_mut() + .filter(|last| u32::from(last.row) == row && last.end_col + 1 == col) + { + last.end_col = end; + } else { + regions.push(crate::api::schema::PaneLinkRegion { + row: row as u16, + start_col: col, + end_col: end, + }); + } + } + byte += len; + } + } + Ok(regions) + } + fn grid_ref(&self, point: ffi::GhosttyPoint) -> Result { let mut grid_ref = ffi::GhosttyGridRef { size: mem::size_of::(), @@ -1375,6 +1562,16 @@ impl Terminal { end: end_ref, rectangle, }; + self.format_selection(&selection, format, unwrap, trim) + } + + fn format_selection( + &self, + selection: &ffi::GhosttySelection, + format: FormatterFormat, + unwrap: bool, + trim: bool, + ) -> Result { let mut formatter: ffi::GhosttyFormatter = ptr::null_mut(); let options = ffi::GhosttyFormatterTerminalOptions { size: mem::size_of::(), @@ -1389,7 +1586,7 @@ impl Terminal { }, ..Default::default() }, - selection: &selection, + selection, }; unsafe { ffi::ghostty_formatter_terminal_new(ptr::null(), &mut formatter, self.raw, options) @@ -3764,6 +3961,118 @@ mod tests { assert!(!terminal.modify_other_keys_enabled().unwrap()); } + #[test] + fn link_target_includes_offscreen_wrapped_rows() { + let url = "https://example.com/abcdefghijklmnopqrstuv"; + let mut terminal = Terminal::new(20, 2, 1024 * 1024).unwrap(); + terminal.write(url.as_bytes()); + let expected = LinkTarget::Text { + text: url.to_owned(), + clicked_byte: 20, + }; + assert_eq!(terminal.viewport_link_target(0, 0).unwrap(), Some(expected)); + terminal.scroll_viewport_row(0); + assert_eq!( + terminal.viewport_link_target(0, 0).unwrap(), + Some(LinkTarget::Text { + text: url.to_owned(), + clicked_byte: 0 + }) + ); + } + + #[test] + fn link_target_prefers_explicit_uri_and_does_not_join_hard_lines() { + let mut terminal = Terminal::new(20, 4, 1024).unwrap(); + terminal.write(b"\x1b]8;;https://example.com/real\x1b\\label\x1b]8;;\x1b\\\r\nhttps://example.com/\r\ntail"); + assert_eq!( + terminal.viewport_link_target(2, 0).unwrap(), + Some(LinkTarget::Uri("https://example.com/real".into())) + ); + assert_eq!( + terminal.viewport_link_target(1, 2).unwrap(), + Some(LinkTarget::Text { + text: "tail".into(), + clicked_byte: 1 + }) + ); + } + + #[test] + fn link_target_stops_at_hard_line_when_clicking_last_column() { + let mut terminal = Terminal::new(20, 3, 1024).unwrap(); + terminal.write(b"https://example.com/x\r\nnot-part-of-url"); + assert_eq!( + terminal.viewport_link_target(19, 0).unwrap(), + Some(LinkTarget::Text { + text: "https://example.com/x".into(), + clicked_byte: 19 + }) + ); + } + + #[test] + fn link_target_wide_explicit_label_works_on_both_cell_halves() { + let mut terminal = Terminal::new(20, 2, 1024).unwrap(); + terminal.write("\x1b]8;;https://example.com/\x1b\\路径\x1b]8;;\x1b\\".as_bytes()); + for col in 0..4 { + assert_eq!( + terminal.viewport_link_target(col, 0).unwrap(), + Some(LinkTarget::Uri("https://example.com/".into())) + ); + } + } + + #[test] + fn link_target_selection_budget_is_shared_across_both_directions() { + let mut terminal = Terminal::new(20, 2, 1024).unwrap(); + terminal.write(b" abc "); + let boundaries = [u32::from(b' ')]; + let options = ffi::GhosttyTerminalSelectWordOptions { + size: mem::size_of::(), + ref_: terminal.grid_ref(ghostty_viewport_point(2, 0)).unwrap(), + boundary_codepoints: boundaries.as_ptr(), + boundary_codepoints_len: boundaries.len(), + }; + for budget in 0..=5 { + let mut selection = ffi::GhosttySelection::default(); + let result = unsafe { + ffi::ghostty_terminal_select_word_bounded( + terminal.raw, + &options, + budget, + &mut selection, + ) + }; + if budget < 5 { + assert_eq!( + result, + ffi::GhosttyResult_GHOSTTY_NO_VALUE, + "budget={budget}" + ); + } else { + result.into_result().unwrap(); + assert_eq!( + terminal + .format_selection(&selection, FormatterFormat::Plain, true, false) + .unwrap(), + "abc" + ); + } + } + } + + #[test] + fn link_target_rejects_oversized_token_without_returning_a_prefix() { + let mut terminal = Terminal::new(100, 4, 16 * 1024 * 1024).unwrap(); + let text = format!("https://example.com/{}", "a".repeat(1_000_000)); + terminal.write(text.as_bytes()); + let started = std::time::Instant::now(); + let target = terminal.viewport_link_target(0, 0).unwrap(); + eprintln!("million-byte token lookup: {:?}", started.elapsed()); + assert!(target.is_none()); + } + #[test] fn terminal_read_text_viewport_unwraps_soft_wrapped_selection() { let mut terminal = Terminal::new(5, 3, 0).unwrap(); diff --git a/src/input/encode.rs b/src/input/encode.rs index c8cf437509..fd8c8bf1ef 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -26,8 +26,21 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec< // Layout-independent `Ctrl`/`Super` shortcuts: `ctrl+ц` must reach the pane // as `ctrl+w` (and `ctrl+о` as `ctrl+j`/LF), using the Kitty base-layout key // when reported, else the Russian fallback table. Plain typing is untouched. + // Normalized first so the Super preservation below encodes the Latin base. let key = key.normalized_for_shortcut(); + // Super has no legacy character encoding. Preserve the chord with CSI-u + // instead of leaking the unmodified character into the pane. + if matches!(protocol, KeyboardProtocol::Legacy) + && key.kind != crossterm::event::KeyEventKind::Release + && matches!(key.code, KeyCode::Char(_)) + && key.modifiers.contains(KeyModifiers::SUPER) + { + if let Some(bytes) = try_encode_csi_u(&key, 0) { + return bytes; + } + } + // REPORT_ALL_KEYS must retain physical press/repeat/release semantics instead of // reducing a native key to its layout-generated text. let preserve_physical_key = key.has_physical_identity() && protocol.reports_all_keys(); @@ -1237,6 +1250,19 @@ mod tests { } } + #[test] + fn legacy_super_character_preserves_csi_u_chord() { + let sequence = "\x1b[99;9u"; + let key = parse_terminal_key_sequence(sequence).expect("Super+C CSI-u key"); + + assert_eq!(key.code, KeyCode::Char('c')); + assert_eq!(key.modifiers, KeyModifiers::SUPER); + assert_eq!( + encode_terminal_key(key, KeyboardProtocol::Legacy), + sequence.as_bytes() + ); + } + #[test] fn kitty_shifted_symbol_pair_matrix_is_encoded_as_text() { let cases = [('1', '!'), ('/', '?'), ('[', '{')]; diff --git a/src/input/parse.rs b/src/input/parse.rs index 7ab2b69d40..3feb51c8fa 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -265,6 +265,10 @@ fn parse_xterm_modified_special_sequence(data: &str) -> Option { "3" => KeyCode::Delete, "5" => KeyCode::PageUp, "6" => KeyCode::PageDown, + "11" => KeyCode::F(1), + "12" => KeyCode::F(2), + "13" => KeyCode::F(3), + "14" => KeyCode::F(4), "15" => KeyCode::F(5), "17" => KeyCode::F(6), "18" => KeyCode::F(7), @@ -324,6 +328,7 @@ fn kitty_codepoint_to_keycode(codepoint: u32) -> Option { 57361 => Some(KeyCode::PrintScreen), 57362 => Some(KeyCode::Pause), 57363 => Some(KeyCode::Menu), + 57364..=57375 => Some(KeyCode::F((codepoint - 57364 + 1) as u8)), 57376..=57398 => Some(KeyCode::F((codepoint - 57376 + 13) as u8)), 57399 => Some(KeyCode::Char('0')), 57400 => Some(KeyCode::Char('1')), @@ -380,15 +385,10 @@ fn kitty_codepoint_to_keycode(codepoint: u32) -> Option { 57452 => Some(KeyCode::Modifier(ModifierKeyCode::RightMeta)), 57453 => Some(KeyCode::Modifier(ModifierKeyCode::IsoLevel3Shift)), 57454 => Some(KeyCode::Modifier(ModifierKeyCode::IsoLevel5Shift)), - value if is_kitty_functional_codepoint(value) => None, value => char::from_u32(value).map(KeyCode::Char), } } -fn is_kitty_functional_codepoint(codepoint: u32) -> bool { - (57358..=57454).contains(&codepoint) -} - #[allow(dead_code)] // Reserved for the upcoming raw stdin parser. fn key_modifiers_from_u8(modifier: u8) -> KeyModifiers { let mut mods = KeyModifiers::empty(); @@ -654,9 +654,31 @@ mod tests { crossterm::event::KeyEventKind::Press, None, ); - assert_eq!(parse_terminal_key_sequence("\x1b[11;2~"), None); - assert_eq!(parse_terminal_key_sequence("\x1b[14;1~"), None); - assert_eq!(parse_terminal_key_sequence("\x1b[14;3~"), None); + } + + #[test] + fn parse_parameterized_csi_tilde_f1_through_f4() { + // foot and some rxvt-style hosts emit F1-F4 as `CSI ;~` + // (for example F3 as `\x1b[13;1:1~`), reusing the same code table as the + // unmodified `\x1b[11~`..`\x1b[14~` forms. + let cases = [ + ("\x1b[11;2~", KeyCode::F(1), KeyModifiers::SHIFT), + ("\x1b[12;1~", KeyCode::F(2), KeyModifiers::empty()), + ("\x1b[13;1:1~", KeyCode::F(3), KeyModifiers::empty()), + ("\x1b[13;2~", KeyCode::F(3), KeyModifiers::SHIFT), + ("\x1b[14;3~", KeyCode::F(4), KeyModifiers::ALT), + ]; + + for (sequence, code, modifiers) in cases { + let parsed = parse_terminal_key_sequence(sequence).unwrap(); + assert_terminal_key_eq( + parsed, + code, + modifiers, + crossterm::event::KeyEventKind::Press, + None, + ); + } } #[test] @@ -1047,8 +1069,27 @@ mod tests { } #[test] - fn unknown_kitty_functional_key_remains_unsupported() { - assert!(parse_terminal_key_sequence("\x1b[57364;1u").is_none()); + fn kitty_f1_through_f12_codepoints_are_recognized() { + let cases = [ + ("\x1b[57364;1u", KeyCode::F(1)), + ("\x1b[57365;1u", KeyCode::F(2)), + ("\x1b[57366;1u", KeyCode::F(3)), + ("\x1b[57367;1u", KeyCode::F(4)), + ("\x1b[57368;1u", KeyCode::F(5)), + ("\x1b[57375;1u", KeyCode::F(12)), + ("\x1b[57376;1u", KeyCode::F(13)), + ]; + + for (sequence, code) in cases { + let parsed = parse_terminal_key_sequence(sequence).unwrap(); + assert_terminal_key_eq( + parsed, + code, + KeyModifiers::empty(), + crossterm::event::KeyEventKind::Press, + None, + ); + } } fn assert_fixture_corpus_parses(corpus: &str) { diff --git a/src/integration/actions.rs b/src/integration/actions.rs index e8b3ab6375..d51682f8b1 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -4,11 +4,11 @@ use super::registry::{integration_target_label, integration_target_supported}; use super::targets::{ install_antigravity_cli, install_claude, install_codex, install_copilot, install_cursor, install_devin, install_droid, install_grok, install_hermes, install_kilo, install_kimi, - install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, install_qwen, - uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, + install_letta, install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, + install_qwen, uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes, - uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, - uninstall_pi, uninstall_qodercli, uninstall_qwen, + uninstall_kilo, uninstall_kimi, uninstall_letta, uninstall_mastracode, uninstall_omp, + uninstall_opencode, uninstall_pi, uninstall_qodercli, uninstall_qwen, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -22,6 +22,59 @@ pub(crate) fn install_target( result } +/// Experimental Letta install that bypasses the frozen client endpoint +/// `IntegrationTarget` enum. Fold into the agent registry when it lands. +pub(crate) fn install_experimental_letta() -> io::Result> { + let result = install_letta().map(|installed| { + vec![ + format!( + "installed letta integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured letta settings at {}", + installed.settings_path.display() + ), + ] + }); + let outcome = if result.is_ok() { "ok" } else { "error" }; + crate::logging::integration_action("install", "letta", outcome); + result +} + +/// Experimental Letta uninstall counterpart. +pub(crate) fn uninstall_experimental_letta() -> io::Result> { + let result = uninstall_letta().map(|result| { + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed letta hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no letta hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr letta hook entry from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr letta hook entry found in {}", + result.settings_path.display() + )); + } + messages + }); + let outcome = if result.is_ok() { "ok" } else { "error" }; + crate::logging::integration_action("uninstall", "letta", outcome); + result +} + fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Result> { if !integration_target_supported(target) { return Err(io::Error::other(format!( diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1 index fc59686561..d62c5b9f9f 100644 --- a/src/integration/assets/claude/herdr-agent-state.ps1 +++ b/src/integration/assets/claude/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=9 +# HERDR_INTEGRATION_VERSION=10 param([string]$Action = "") diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 5d11a80c2d..e0b88ce78b 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=9 +# HERDR_INTEGRATION_VERSION=10 set -eu diff --git a/src/integration/assets/letta/herdr-agent-session.ps1 b/src/integration/assets/letta/herdr-agent-session.ps1 new file mode 100644 index 0000000000..27f40e9bd0 --- /dev/null +++ b/src/integration/assets/letta/herdr-agent-session.ps1 @@ -0,0 +1,51 @@ +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=letta +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +if ($Action -ne "session") { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.conversation_id)) { exit 0 } + +$sessionId = [string]$payload.conversation_id +if ($sessionId -eq "default") { + if ([string]::IsNullOrWhiteSpace($payload.agent_id)) { exit 0 } + $sessionId = "default:" + [string]$payload.agent_id +} + +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +$source = if ($payload.is_new_session -eq $true) { "new" } else { "resume" } +$commandArgs = @( + "pane", "report-agent-session", $env:HERDR_PANE_ID, + "--source", "herdr:letta", "--agent", "letta", + "--agent-session-id", $sessionId, + "--seq", [string][DateTime]::UtcNow.Ticks, + "--session-start-source", $source +) +$job = $null +try { + $job = Start-Job -ScriptBlock { + param($Executable, [object[]]$Arguments) + & $Executable @Arguments *> $null + } -ArgumentList $herdr, (,$commandArgs) + if ($null -eq (Wait-Job -Job $job -Timeout 1)) { + Stop-Job -Job $job + } +} catch { + # Hook failures must stay silent because Letta injects output into the next prompt. + $null = $_ +} finally { + if ($null -ne $job) { + Remove-Job -Job $job -Force -ErrorAction SilentlyContinue + } +} diff --git a/src/integration/assets/letta/herdr-agent-session.sh b/src/integration/assets/letta/herdr-agent-session.sh new file mode 100644 index 0000000000..d6e052d66c --- /dev/null +++ b/src/integration/assets/letta/herdr-agent-session.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=letta +# HERDR_INTEGRATION_VERSION=1 + +[ "${1:-}" = "session" ] || exit 0 +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +if [ -n "${HERDR_BIN_PATH:-}" ]; then + [ -x "$HERDR_BIN_PATH" ] || exit 0 +else + command -v herdr >/dev/null 2>&1 || exit 0 +fi +command -v python3 >/dev/null 2>&1 || exit 0 + +# SessionStart stdout is injected into Letta's next user message. Keep every +# path silent, including successful reports and malformed hook input. +python3 -c ' +import json +import os +import subprocess +import sys +import time + +try: + payload = json.load(sys.stdin) + conversation_id = payload.get("conversation_id") + agent_id = payload.get("agent_id") + if not isinstance(conversation_id, str) or not conversation_id: + raise ValueError + if conversation_id == "default": + if not isinstance(agent_id, str) or not agent_id: + raise ValueError + session_id = "default:" + agent_id + else: + session_id = conversation_id + + command = os.environ.get("HERDR_BIN_PATH") or "herdr" + args = [ + command, "pane", "report-agent-session", os.environ["HERDR_PANE_ID"], + "--source", "herdr:letta", "--agent", "letta", + "--agent-session-id", session_id, "--seq", str(time.time_ns()), + "--session-start-source", "new" if payload.get("is_new_session") else "resume", + ] + subprocess.run( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=1, + check=False, + ) +except Exception: + pass +' 2>/dev/null || true diff --git a/src/integration/claude_settings.rs b/src/integration/claude_settings.rs index 5bc61d754d..5fa2df885f 100644 --- a/src/integration/claude_settings.rs +++ b/src/integration/claude_settings.rs @@ -14,6 +14,10 @@ use super::config_edit::{ is_matching_command_hook, }; +// Claude's documented SessionStart sources. Grok imports Claude hooks but uses +// `new`/`load`; filter before it starts an unnecessary hook process. +const SESSION_START_MATCHER: &str = "^(startup|resume|clear|compact|fork)$"; + struct HookRemoval { event: &'static str, actions: &'static [&'static str], @@ -74,7 +78,7 @@ pub(crate) fn install(content: &str, settings_path: &Path, hook_path: &Path) -> "SessionStart", hook_command(hook_path, Some("session")), 10, - Some("*"), + Some(SESSION_START_MATCHER), )?; if desired == original { @@ -345,7 +349,7 @@ fn removal_commands(policy: &HookRemoval, hook_path: &Path) -> Vec { fn canonical_hook_value(hook_path: &Path) -> Value { serde_json_value!({ - "matcher": "*", + "matcher": SESSION_START_MATCHER, "hooks": [{ "type": "command", "command": hook_command(hook_path, Some("session")), @@ -357,7 +361,7 @@ fn canonical_hook_value(hook_path: &Path) -> Value { fn canonical_hook_input(hook_path: &Path) -> CstInputValue { let command = hook_command(hook_path, Some("session")); json!({ - matcher: "*", + matcher: SESSION_START_MATCHER, hooks: [{ "type": "command", command: command, @@ -515,7 +519,7 @@ fn append_to_container( fn canonical_hook_json(hook_path: &Path) -> io::Result { let command = serde_json::to_string(&hook_command(hook_path, Some("session")))?; Ok(format!( - "{{\"matcher\":\"*\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}}]}}" + "{{\"matcher\":\"{SESSION_START_MATCHER}\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}}]}}" )) } @@ -675,12 +679,30 @@ mod tests { } } + #[test] + fn install_scopes_claude_session_start_sources() { + let (settings_path, hook_path) = paths(); + let installed = install("{}", settings_path, hook_path).unwrap(); + let settings: Value = serde_json::from_str(&installed).unwrap(); + let matcher = settings["hooks"]["SessionStart"][0]["matcher"] + .as_str() + .unwrap(); + assert_eq!(matcher, "^(startup|resume|clear|compact|fork)$"); + let pattern = regex::Regex::new(matcher).unwrap(); + for source in ["startup", "resume", "clear", "compact", "fork"] { + assert!(pattern.is_match(source), "Claude source: {source}"); + } + for source in ["new", "load", "", "future-source", "startup-extra"] { + assert!(!pattern.is_match(source), "non-Claude source: {source}"); + } + } + #[test] fn install_is_a_byte_exact_noop_for_a_canonical_hook() { let (settings_path, hook_path) = paths(); let command = serde_json::to_string(&hook_command(hook_path, Some("session"))).unwrap(); let input = format!( - "{{\"hooks\":{{\"SessionStart\":[{{\"hooks\":[{{\"timeout\":10,\"command\":{command},\"type\":\"command\"}}],\"matcher\":\"*\"}}]}},\"escaped\":\"\\u0061\"}} \r\n\r\n" + "{{\"hooks\":{{\"SessionStart\":[{{\"hooks\":[{{\"timeout\":10,\"command\":{command},\"type\":\"command\"}}],\"matcher\":\"{SESSION_START_MATCHER}\"}}]}},\"escaped\":\"\\u0061\"}} \r\n\r\n" ); let updated = install(&input, settings_path, hook_path).unwrap(); @@ -688,6 +710,39 @@ mod tests { assert_eq!(updated, input); } + #[test] + fn install_migrates_wildcard_session_start_and_preserves_user_hook() { + let (settings_path, hook_path) = paths(); + let command = serde_json::to_string(&hook_command(hook_path, Some("session"))).unwrap(); + let user_hook = r#"{ "type" : "command", "command" : "echo keep", "timeout" : 3 }"#; + let input = format!( + "{{\n \"hooks\": {{\n \"SessionStart\": [{{\"matcher\":\"*\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}},{user_hook}]}}]\n }}\n}}\n\n" + ); + let installed = install(&input, settings_path, hook_path).unwrap(); + assert!(installed.contains(user_hook)); + assert!(installed.ends_with("}\n\n")); + let settings: Value = serde_json::from_str(&installed).unwrap(); + let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0]["matcher"], "*"); + assert_eq!(groups[0]["hooks"].as_array().unwrap().len(), 1); + assert_eq!(groups[0]["hooks"][0]["command"], "echo keep"); + assert_eq!(groups[1], canonical_hook_value(hook_path)); + assert_eq!( + install(&installed, settings_path, hook_path).unwrap(), + installed + ); + + let removed = uninstall(&installed, settings_path, hook_path).unwrap(); + assert!(removed.contains(user_hook)); + assert!(!removed.contains(&command)); + let settings: Value = serde_json::from_str(&removed).unwrap(); + assert_eq!( + settings["hooks"]["SessionStart"].as_array().unwrap().len(), + 1 + ); + } + #[test] fn install_preserves_canonical_session_start_position_during_migration() { let (settings_path, hook_path) = paths(); diff --git a/src/integration/config_edit.rs b/src/integration/config_edit.rs index 630682ce4f..812907e793 100644 --- a/src/integration/config_edit.rs +++ b/src/integration/config_edit.rs @@ -428,12 +428,24 @@ pub(crate) fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> Stri .map(|offset| plugins_index + 1 + offset); if let Some(enabled_index) = enabled_index { - let line = lines[enabled_index].trim(); - if line == "enabled: []" || line == "enabled: [] # herdr" { - if enabled { - lines[enabled_index] = " enabled:".to_string(); - lines.insert(enabled_index + 1, " - herdr-agent-state".to_string()); + if let Some(mut items) = yaml_key_value_at_indent(&lines[enabled_index], 2, "enabled") + .and_then(yaml_flow_sequence_items) + { + let existing_item_index = items + .iter() + .position(|item| yaml_scalar_value(item) == HERMES_PLUGIN_INSTALL_NAME); + + match (enabled, existing_item_index) { + (true, Some(_)) | (false, None) => return content.to_string(), + (true, None) => items.insert(0, HERMES_PLUGIN_INSTALL_NAME.to_string()), + (false, Some(index)) => { + items.remove(index); + } } + + let comment = yaml_inline_comment(&lines[enabled_index]); + let replacement = hermes_enabled_plugin_lines(&items, comment); + lines.splice(enabled_index..enabled_index + 1, replacement); return join_yaml_lines(lines, trailing_newline); } @@ -463,7 +475,7 @@ pub(crate) fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> Stri if let Some(mut items) = plugins_inline_items { let existing_item_index = items .iter() - .position(|item| item == HERMES_PLUGIN_INSTALL_NAME); + .position(|item| yaml_scalar_value(item) == HERMES_PLUGIN_INSTALL_NAME); match (enabled, existing_item_index) { (true, Some(_)) | (false, None) => return content.to_string(), @@ -473,7 +485,8 @@ pub(crate) fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> Stri } } - let replacement = hermes_flat_plugin_lines(&items); + let comment = yaml_inline_comment(&lines[plugins_index]); + let replacement = hermes_flat_plugin_lines(&items, comment); lines.splice(plugins_index..plugins_end, replacement); return join_yaml_lines(lines, trailing_newline); } @@ -503,16 +516,37 @@ pub(crate) fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> Stri content.to_string() } -pub(crate) fn hermes_flat_plugin_lines(items: &[String]) -> Vec { +pub(crate) fn hermes_flat_plugin_lines(items: &[String], comment: Option<&str>) -> Vec { if items.is_empty() { - return vec!["plugins: []".to_string()]; + return vec![with_yaml_inline_comment("plugins: []".to_string(), comment)]; } - let mut lines = vec!["plugins:".to_string()]; + let mut lines = vec![with_yaml_inline_comment("plugins:".to_string(), comment)]; lines.extend(items.iter().map(|item| format!(" - {item}"))); lines } +pub(crate) fn hermes_enabled_plugin_lines(items: &[String], comment: Option<&str>) -> Vec { + if items.is_empty() { + return vec![with_yaml_inline_comment( + " enabled: []".to_string(), + comment, + )]; + } + + let mut lines = vec![with_yaml_inline_comment(" enabled:".to_string(), comment)]; + lines.extend(items.iter().map(|item| format!(" - {item}"))); + lines +} + +fn with_yaml_inline_comment(mut line: String, comment: Option<&str>) -> String { + if let Some(comment) = comment { + line.push(' '); + line.push_str(comment.trim_end()); + } + line +} + pub(crate) fn top_level_yaml_key_index(lines: &[String], key: &str) -> Option { lines .iter() @@ -619,7 +653,7 @@ pub(crate) fn yaml_flow_sequence_items(value: &str) -> Option> { current.push(ch); } ',' => { - items.push(yaml_scalar_value(¤t)); + items.push(current.trim().to_string()); current.clear(); } _ => current.push(ch), @@ -630,7 +664,7 @@ pub(crate) fn yaml_flow_sequence_items(value: &str) -> Option> { return None; } - items.push(yaml_scalar_value(¤t)); + items.push(current.trim().to_string()); Some(items) } @@ -648,6 +682,13 @@ pub(crate) fn yaml_scalar_value(value: &str) -> String { } pub(crate) fn strip_yaml_inline_comment(value: &str) -> &str { + match yaml_inline_comment(value) { + Some(comment) => value[..value.len() - comment.len()].trim_end(), + None => value, + } +} + +pub(crate) fn yaml_inline_comment(value: &str) -> Option<&str> { let mut quote = None; let mut escaped = false; @@ -667,13 +708,13 @@ pub(crate) fn strip_yaml_inline_comment(value: &str) -> &str { match ch { '"' | '\'' => quote = Some(ch), '#' if index == 0 || value[..index].ends_with(char::is_whitespace) => { - return value[..index].trim_end(); + return Some(&value[index..]); } _ => {} } } - value + None } pub(crate) fn join_yaml_lines(lines: Vec, trailing_newline: bool) -> String { diff --git a/src/integration/env.rs b/src/integration/env.rs index 48320c72cf..bec9e23bfc 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -27,7 +27,7 @@ pub(crate) const HERMES_HOME_ENV_VAR: &str = "HERMES_HOME"; pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) { cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path()); - if let Ok(executable) = std::env::current_exe() { + if let Ok(executable) = crate::platform::launch_executable() { cmd.env("HERDR_BIN_PATH", executable); } } @@ -175,6 +175,10 @@ pub(crate) fn qwen_dir() -> io::Result { config_dir_from_env_or_home(QWEN_HOME_ENV_VAR, &[".qwen"]) } +pub(crate) fn letta_dir() -> io::Result { + Ok(home_dir()?.join(".letta")) +} + pub(crate) fn cursor_dir() -> io::Result { config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"]) } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 55dcb2be6c..97163da56c 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -10,17 +10,27 @@ mod targets; mod types; mod version; -pub(crate) use actions::{install_target, uninstall_target}; +pub(crate) use actions::{ + install_experimental_letta, install_target, uninstall_experimental_letta, uninstall_target, +}; #[cfg(test)] pub(crate) use env::integration_env_lock; pub(crate) use env::{ apply_pane_base_env, HERDR_PANE_ID_ENV_VAR, HERDR_TAB_ID_ENV_VAR, HERDR_WORKSPACE_ID_ENV_VAR, }; pub(crate) use registry::{ - installed_integration_statuses, integration_recommendations, integration_target_label, - print_outdated_update_notice, + experimental_letta_integration_status, installed_integration_statuses, + integration_recommendations, integration_target_label, print_outdated_update_notice, +}; +pub(crate) use types::{ + ExperimentalIntegrationStatus, IntegrationRecommendation, IntegrationStatus, + IntegrationStatusKind, }; -pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, IntegrationStatusKind}; + +/// CLI labels for experimental integrations that are intentionally not part of +/// the frozen client endpoint `IntegrationTarget` enum. Empty this list once the +/// agent registry provides first-class target registration. +pub(crate) const EXPERIMENTAL_INTEGRATION_TARGET_LABELS: &[&str] = &["letta"]; const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); @@ -38,7 +48,7 @@ const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/claude/herdr-agent-state.sh") }; -const CLAUDE_INTEGRATION_VERSION: u32 = 9; +const CLAUDE_INTEGRATION_VERSION: u32 = 10; const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -207,6 +217,18 @@ const QWEN_HOOK_ASSET: &str = if cfg!(windows) { }; const QWEN_INTEGRATION_VERSION: u32 = 1; const QWEN_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const LETTA_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-session.ps1" +} else { + "herdr-agent-session.sh" +}; +const LETTA_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/letta/herdr-agent-session.ps1") +} else { + include_str!("assets/letta/herdr-agent-session.sh") +}; +const LETTA_INTEGRATION_VERSION: u32 = 1; +const LETTA_HOOK_TIMEOUT_MS: u64 = 10_000; const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ ("SessionStart", "idle"), ("UserPromptSubmit", "working"), diff --git a/src/integration/registry.rs b/src/integration/registry.rs index f3bb8c0487..9185c96f19 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -441,30 +441,33 @@ fn opencode_tui_integration_is_valid(plugin_path: &Path, expected_version: u32) .is_some_and(|version| version >= expected_version))) } -pub(crate) fn integration_status_at( - target: crate::api::schema::IntegrationTarget, - path: PathBuf, +fn integration_state_for_path( + path: &Path, expected_version: u32, -) -> super::IntegrationStatus { +) -> (super::IntegrationStatusKind, Option) { if !path.is_file() { - return super::IntegrationStatus { - target, - path, - state: super::IntegrationStatusKind::NotInstalled, - installed_version: None, - expected_version, - }; + return (super::IntegrationStatusKind::NotInstalled, None); } - let installed_version = fs::read_to_string(&path) + let installed_version = fs::read_to_string(path) .ok() .and_then(|content| parse_integration_version(&content)); - let mut state = if installed_version.is_some_and(|version| version >= expected_version) { + let state = if installed_version.is_some_and(|version| version >= expected_version) { super::IntegrationStatusKind::Current } else { super::IntegrationStatusKind::Outdated }; + (state, installed_version) +} + +pub(crate) fn integration_status_at( + target: crate::api::schema::IntegrationTarget, + path: PathBuf, + expected_version: u32, +) -> super::IntegrationStatus { + let (mut state, installed_version) = integration_state_for_path(&path, expected_version); + // Grok only invokes the hook when the herdr-owned `hooks/herdr.json` // registers it, so a current hook script with a missing or broken config // is a nonfunctional install: report it as outdated so `herdr integration @@ -491,6 +494,27 @@ pub(crate) fn integration_status_at( } } +/// Letta is intentionally kept out of the frozen client endpoint +/// `IntegrationTarget` enum so published generation-1 clients never receive an +/// unknown variant. It is installable and reportable as an experimental +/// CLI-only target until the agent registry replaces the enum-keyed registry. +pub(crate) fn experimental_letta_integration_status() -> Option +{ + let path = letta_dir() + .ok()? + .join("hooks") + .join(super::LETTA_HOOK_INSTALL_NAME); + let (state, installed_version) = + integration_state_for_path(&path, super::LETTA_INTEGRATION_VERSION); + Some(super::ExperimentalIntegrationStatus { + label: "letta", + path, + state, + installed_version, + expected_version: super::LETTA_INTEGRATION_VERSION, + }) +} + pub(crate) fn parse_integration_version(content: &str) -> Option { content.lines().find_map(|line| { let marker_line = line diff --git a/src/integration/targets.rs b/src/integration/targets.rs index d204c55293..cab8f7ee15 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -21,8 +21,8 @@ use super::config_edit::{ }; use super::env::{ antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, - grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, - opencode_dir, opencode_state_dir, pi_extension_dir, qodercli_dir, qwen_dir, + grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, letta_dir, mastracode_dir, + omp_extension_dir, opencode_dir, opencode_state_dir, pi_extension_dir, qodercli_dir, qwen_dir, }; use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, @@ -37,10 +37,10 @@ use super::types::{ CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, GrokInstallPaths, GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, - KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths, - MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths, - OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, - QwenInstallPaths, QwenUninstallResult, + KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, LettaInstallPaths, + LettaUninstallResult, MastracodeInstallPaths, MastracodeUninstallResult, OmpInstallPaths, + OmpUninstallResult, OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, + QodercliInstallPaths, QodercliUninstallResult, QwenInstallPaths, QwenUninstallResult, }; use super::{ ANTIGRAVITY_CLI_HOOK_ASSET, ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, ANTIGRAVITY_CLI_HOOK_EVENTS, @@ -53,7 +53,8 @@ use super::{ GROK_HOOK_ASSET, GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, - KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, + KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, LETTA_HOOK_ASSET, LETTA_HOOK_INSTALL_NAME, + LETTA_HOOK_TIMEOUT_MS, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME, @@ -1021,6 +1022,207 @@ pub(crate) fn install_qwen() -> io::Result { }) } +fn letta_install_artifact_path(path: &Path, role: &str) -> io::Result { + let file_name = path + .file_name() + .ok_or_else(|| io::Error::other(format!("invalid install path: {}", path.display())))?; + let mut artifact_name = file_name.to_os_string(); + artifact_name.push(format!(".herdr-install-{}-{role}", std::process::id())); + Ok(path.with_file_name(artifact_name)) +} + +pub(super) fn prepare_letta_install_file( + target: &Path, + contents: &[u8], + executable: bool, + preserve_permissions: bool, +) -> io::Result<(PathBuf, PathBuf)> { + if target.try_exists()? && !target.is_file() { + return Err(io::Error::other(format!( + "install target is not a file: {}", + target.display() + ))); + } + + let staged = letta_install_artifact_path(target, "staged")?; + let backup = letta_install_artifact_path(target, "backup")?; + if staged.try_exists()? || backup.try_exists()? { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("stale install artifact exists for {}", target.display()), + )); + } + + let prepare_result = (|| { + fs::write(&staged, contents)?; + if preserve_permissions && target.is_file() { + fs::set_permissions(&staged, fs::metadata(target)?.permissions())?; + } + if executable { + make_executable(&staged)?; + } + Ok(()) + })(); + if let Err(err) = prepare_result { + let _ = remove_file_if_exists(&staged); + return Err(err); + } + + Ok((staged, backup)) +} + +fn combine_letta_install_errors(primary: io::Error, rollback: io::Result<()>) -> io::Error { + match rollback { + Ok(()) => primary, + Err(rollback_err) => io::Error::new( + primary.kind(), + format!("{primary}; rollback failed: {rollback_err}"), + ), + } +} + +pub(super) fn publish_letta_install_file( + target: &Path, + staged: &Path, + backup: &Path, +) -> io::Result { + let had_original = target.try_exists()?; + if had_original { + fs::rename(target, backup)?; + } + + if let Err(err) = fs::rename(staged, target) { + let rollback = if had_original { + fs::rename(backup, target) + } else { + Ok(()) + }; + return Err(combine_letta_install_errors(err, rollback)); + } + + Ok(had_original) +} + +pub(super) fn rollback_letta_install_file( + target: &Path, + backup: &Path, + had_original: bool, +) -> io::Result<()> { + remove_file_if_exists(target)?; + if had_original { + fs::rename(backup, target)?; + } + Ok(()) +} + +fn cleanup_letta_install_artifact(path: &Path) { + if let Err(err) = remove_file_if_exists(path) { + tracing::warn!(path = %path.display(), %err, "failed to remove Letta install artifact"); + } +} + +fn ensure_letta_session_hook(hooks: &mut Map, command: String) -> io::Result<()> { + let entries = hooks + .entry("SessionStart".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| io::Error::other("hook entries for SessionStart must be an array"))?; + + entries.push(json!({ + "hooks": [{ + "type": "command", + "command": command, + "timeout": LETTA_HOOK_TIMEOUT_MS, + "quiet": true, + }], + })); + Ok(()) +} + +pub(crate) fn install_letta() -> io::Result { + let dir = letta_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "letta code config directory not found at {}. install letta code first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(LETTA_HOOK_INSTALL_NAME); + + let settings_path = dir.join("settings.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "letta settings", + "letta settings hooks", + )?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + ensure_letta_session_hook(hooks, hook_command(&hook_path, Some("session")))?; + + let settings_contents = serde_json::to_string_pretty(&settings)?; + let (hook_staged, hook_backup) = + prepare_letta_install_file(&hook_path, LETTA_HOOK_ASSET.as_bytes(), true, false)?; + let (settings_staged, settings_backup) = + match prepare_letta_install_file(&settings_path, settings_contents.as_bytes(), false, true) + { + Ok(paths) => paths, + Err(err) => { + cleanup_letta_install_artifact(&hook_staged); + return Err(err); + } + }; + + let hook_had_original = match publish_letta_install_file(&hook_path, &hook_staged, &hook_backup) + { + Ok(had_original) => had_original, + Err(err) => { + cleanup_letta_install_artifact(&hook_staged); + cleanup_letta_install_artifact(&settings_staged); + return Err(err); + } + }; + + let settings_had_original = + match publish_letta_install_file(&settings_path, &settings_staged, &settings_backup) { + Ok(had_original) => had_original, + Err(err) => { + let err = combine_letta_install_errors( + err, + rollback_letta_install_file(&hook_path, &hook_backup, hook_had_original), + ); + cleanup_letta_install_artifact(&settings_staged); + return Err(err); + } + }; + + if hook_had_original { + cleanup_letta_install_artifact(&hook_backup); + } + if settings_had_original { + cleanup_letta_install_artifact(&settings_backup); + } + + Ok(LettaInstallPaths { + hook_path, + settings_path, + }) +} + pub(crate) fn install_cursor() -> io::Result { let dir = cursor_dir()?; if !dir.is_dir() { @@ -1163,6 +1365,46 @@ pub(crate) fn uninstall_qwen() -> io::Result { }) } +pub(crate) fn uninstall_letta() -> io::Result { + let dir = letta_dir()?; + let hook_path = dir.join("hooks").join(LETTA_HOOK_INSTALL_NAME); + let settings_path = dir.join("settings.json"); + let mut updated_settings = false; + + if settings_path.is_file() { + let mut settings = serde_json::from_str::(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "letta settings", + "letta settings hooks", + )? { + updated_settings |= + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(LettaUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + pub(crate) fn uninstall_cursor() -> io::Result { let cursor_home = cursor_dir()?; let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME); diff --git a/src/integration/tests.rs b/src/integration/tests.rs index e8aad72b1c..eb310637b7 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -957,7 +957,10 @@ fn install_claude_writes_hook_and_updates_settings() { ); assert_eq!(hook_content, CLAUDE_HOOK_ASSET); assert!(settings["permissions"]["allow"].is_array()); - assert_eq!(settings["hooks"]["SessionStart"][0]["matcher"], "*"); + assert_eq!( + settings["hooks"]["SessionStart"][0]["matcher"], + "^(startup|resume|clear|compact|fork)$" + ); assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] .as_str() .unwrap() @@ -1105,7 +1108,7 @@ fn install_claude_removes_deprecated_completion_hooks_and_preserves_user_hooks() } #[test] -fn claude_v1_integration_status_is_outdated() { +fn claude_v9_integration_status_is_outdated_until_reinstalled() { let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); @@ -1114,7 +1117,7 @@ fn claude_v1_integration_status_is_outdated() { let hook_path = claude_hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME); fs::write( &hook_path, - "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=1\n", + "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=9\n", ) .unwrap(); std::env::set_var("HOME", &home); @@ -1126,10 +1129,19 @@ fn claude_v1_integration_status_is_outdated() { .unwrap(); assert_eq!(claude.path, hook_path); - assert_eq!(claude.installed_version, Some(1)); - assert_eq!(claude.expected_version, 9); + assert_eq!(claude.installed_version, Some(9)); + assert_eq!(claude.expected_version, 10); assert_eq!(claude.state, IntegrationStatusKind::Outdated); + install_claude().unwrap(); + let status = integration_status_at( + crate::api::schema::IntegrationTarget::Claude, + hook_path, + CLAUDE_INTEGRATION_VERSION, + ); + assert_eq!(status.installed_version, Some(10)); + assert_eq!(status.state, IntegrationStatusKind::Current); + std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } @@ -1157,7 +1169,7 @@ fn claude_v2_integration_status_is_outdated() { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(2)); - assert_eq!(claude.expected_version, 9); + assert_eq!(claude.expected_version, 10); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2691,6 +2703,59 @@ fn install_hermes_converts_flow_plugin_list_to_block_list() { let _ = fs::remove_dir_all(base); } +#[test] +fn install_hermes_converts_inline_enabled_list_to_block_list() { + let config = update_hermes_enabled_plugin("plugins:\n enabled: [example-plugin]\n", true); + assert_eq!( + config, + "plugins:\n enabled:\n - herdr-agent-state\n - example-plugin\n" + ); +} + +#[test] +fn install_hermes_preserves_quoted_inline_enabled_items() { + let config = + update_hermes_enabled_plugin("plugins:\n enabled: [\"null\", 'foo: bar']\n", true); + assert_eq!( + config, + "plugins:\n enabled:\n - herdr-agent-state\n - \"null\"\n - 'foo: bar'\n" + ); +} + +#[test] +fn install_hermes_preserves_inline_enabled_comment() { + let config = update_hermes_enabled_plugin( + "plugins:\n enabled: [example-plugin] # managed locally\n", + true, + ); + assert_eq!( + config, + "plugins:\n enabled: # managed locally\n - herdr-agent-state\n - example-plugin\n" + ); +} + +#[test] +fn install_hermes_preserves_inline_plugins_comment() { + let config = + update_hermes_enabled_plugin("plugins: [platforms/discord] # managed locally\n", true); + assert_eq!( + config, + "plugins: # managed locally\n - herdr-agent-state\n - platforms/discord\n" + ); +} + +#[test] +fn install_hermes_is_idempotent_for_inline_enabled_list_entry() { + let config = update_hermes_enabled_plugin( + "plugins:\n enabled: [herdr-agent-state, example-plugin]\n", + true, + ); + assert_eq!( + config, + "plugins:\n enabled: [herdr-agent-state, example-plugin]\n" + ); +} + #[test] fn install_hermes_is_idempotent_for_quoted_flat_plugin_entry() { let _lock = integration_env_lock(); @@ -2812,6 +2877,30 @@ fn uninstall_hermes_removes_flow_plugin_list_entry() { let _ = fs::remove_dir_all(base); } +#[test] +fn uninstall_hermes_removes_inline_enabled_list_entry() { + let config = update_hermes_enabled_plugin( + "plugins:\n enabled: [example-plugin, herdr-agent-state]\n", + false, + ); + assert_eq!(config, "plugins:\n enabled:\n - example-plugin\n"); +} + +#[test] +fn uninstall_hermes_preserves_quoted_inline_enabled_items() { + let config = update_hermes_enabled_plugin( + "plugins:\n enabled: ['foo: bar', herdr-agent-state]\n", + false, + ); + assert_eq!(config, "plugins:\n enabled:\n - 'foo: bar'\n"); +} + +#[test] +fn uninstall_hermes_converts_single_inline_enabled_entry_to_empty_list() { + let config = update_hermes_enabled_plugin("plugins:\n enabled: [herdr-agent-state]\n", false); + assert_eq!(config, "plugins:\n enabled: []\n"); +} + #[test] fn uninstall_hermes_removes_commented_flat_plugin_entry() { let _lock = integration_env_lock(); @@ -3330,6 +3419,194 @@ fn install_qwen_errors_when_config_dir_missing() { let _ = fs::remove_dir_all(base); } +#[test] +fn install_and_uninstall_letta_preserve_unrelated_settings_and_hooks() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let letta_dir = home.join(".letta"); + fs::create_dir_all(&letta_dir).unwrap(); + let settings_path = letta_dir.join("settings.json"); + fs::write( + &settings_path, + r#"{"theme":"dark","hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user"}]}]}}"#, + ) + .unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + let installed = install_letta().unwrap(); + assert_eq!( + installed.hook_path, + letta_dir.join("hooks").join(LETTA_HOOK_INSTALL_NAME) + ); + let first_install = fs::read_to_string(&settings_path).unwrap(); + let settings: Value = serde_json::from_str(&first_install).unwrap(); + let entries = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["hooks"][0]["command"], "echo user"); + assert!(entries[1].get("matcher").is_none()); + assert_eq!(entries[1]["hooks"][0]["timeout"], LETTA_HOOK_TIMEOUT_MS); + assert_eq!(entries[1]["hooks"][0]["quiet"], true); + assert!(entries[1]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("session")); + assert_eq!(settings["theme"], "dark"); + + install_letta().unwrap(); + assert_eq!(fs::read_to_string(&settings_path).unwrap(), first_install); + + let result = uninstall_letta().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_settings); + assert!(!installed.hook_path.exists()); + let settings: Value = + serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); + assert_eq!(settings["theme"], "dark"); + let remaining = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["hooks"][0]["command"], "echo user"); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn letta_session_hook_is_silent_and_encodes_default_conversation() { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::process::{Command, Stdio}; + + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(home.join(".letta")).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + let installed = install_letta().unwrap(); + + let capture = base.join("args.txt"); + let fake_herdr = base.join("herdr"); + fs::write( + &fake_herdr, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" > '{}'\n", + capture.display() + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&fake_herdr).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fake_herdr, permissions).unwrap(); + + let mut child = Command::new("sh") + .arg(&installed.hook_path) + .arg("session") + .env("HERDR_ENV", "1") + .env("HERDR_PANE_ID", "w1:p2") + .env("HERDR_SOCKET_PATH", "/tmp/herdr.sock") + .env("HERDR_BIN_PATH", &fake_herdr) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all( + br#"{"event_type":"SessionStart","conversation_id":"default","agent_id":"agent-123","is_new_session":false}"#, + ) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + let args = fs::read_to_string(capture).unwrap(); + assert!(args.contains("report-agent-session w1:p2")); + assert!(args.contains("--source herdr:letta --agent letta")); + assert!(args.contains("--agent-session-id default:agent-123")); + assert!(args.contains("--session-start-source resume")); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_letta_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + let err = install_letta().unwrap_err().to_string(); + assert!(err.contains("letta code config directory not found")); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_letta_does_not_publish_hook_when_settings_are_invalid() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let letta_dir = home.join(".letta"); + fs::create_dir_all(&letta_dir).unwrap(); + fs::write(letta_dir.join("settings.json"), "not json").unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + assert!(install_letta().is_err()); + assert!(!letta_dir + .join("hooks") + .join(LETTA_HOOK_INSTALL_NAME) + .exists()); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn letta_staged_install_can_restore_the_prior_file() { + let base = unique_base(); + fs::create_dir_all(&base).unwrap(); + let target = base.join("settings.json"); + fs::write(&target, "old").unwrap(); + + let (staged, backup) = prepare_letta_install_file(&target, b"new", false, true).unwrap(); + let had_original = publish_letta_install_file(&target, &staged, &backup).unwrap(); + assert!(had_original); + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + + rollback_letta_install_file(&target, &backup, had_original).unwrap(); + assert_eq!(fs::read_to_string(&target).unwrap(), "old"); + assert!(!backup.exists()); + + let _ = fs::remove_dir_all(base); +} + #[test] fn install_cursor_writes_hook_and_updates_hooks_json() { let _lock = integration_env_lock(); diff --git a/src/integration/types.rs b/src/integration/types.rs index 8169ab9823..928675f626 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -76,6 +76,12 @@ pub(crate) struct QwenInstallPaths { pub settings_path: PathBuf, } +#[derive(Debug)] +pub(crate) struct LettaInstallPaths { + pub hook_path: PathBuf, + pub settings_path: PathBuf, +} + #[derive(Debug)] pub(crate) struct CursorInstallPaths { pub hook_path: PathBuf, @@ -134,6 +140,14 @@ pub(crate) struct QwenUninstallResult { pub updated_settings: bool, } +#[derive(Debug)] +pub(crate) struct LettaUninstallResult { + pub hook_path: PathBuf, + pub settings_path: PathBuf, + pub removed_hook_file: bool, + pub updated_settings: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct IntegrationStatus { pub target: crate::api::schema::IntegrationTarget, @@ -150,6 +164,17 @@ pub(crate) enum IntegrationStatusKind { Outdated, } +/// Status for an experimental target that is deliberately not part of the +/// frozen client endpoint `IntegrationTarget` enum. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExperimentalIntegrationStatus { + pub label: &'static str, + pub path: PathBuf, + pub state: IntegrationStatusKind, + pub installed_version: Option, + pub expected_version: u32, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct IntegrationRecommendation { pub target: crate::api::schema::IntegrationTarget, diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index e5f00ac29f..4df05ffb44 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -37,6 +37,61 @@ pub(crate) enum Visibility { Hidden, } +#[derive(Debug, Default)] +pub(crate) struct Occlusion { + regions: Vec, + popup_start: usize, +} + +impl Occlusion { + pub(crate) fn cover(&mut self, rect: Rect) { + if !rect.is_empty() { + self.regions.push(rect); + } + } + + pub(crate) fn start_popup(&mut self, rect: Rect) { + self.cover(rect); + self.popup_start = self.regions.len(); + } + + fn covers( + &self, + placement: &SurfaceGraphicsPlacement, + origin: (u16, u16), + cell: HostCellSize, + ) -> bool { + let regions = if matches!( + placement.asset.source, + SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Popup { .. }, + .. + } + ) { + &self.regions[self.popup_start..] + } else { + &self.regions + }; + if regions.is_empty() { + return false; + } + let width = u64::from(cell.width_px); + let height = u64::from(cell.height_px); + let x = + u64::from(origin.0.saturating_add(placement.x)) * width + u64::from(placement.x_offset); + let y = u64::from(origin.1.saturating_add(placement.y)) * height + + u64::from(placement.y_offset); + let right = x + u64::from(placement.cols) * width; + let bottom = y + u64::from(placement.rows) * height; + regions.iter().any(|rect| { + x < u64::from(rect.right()) * width + && right > u64::from(rect.x) * width + && y < u64::from(rect.bottom()) * height + && bottom > u64::from(rect.y) * height + }) + } +} + #[derive(Debug, Default)] pub(crate) struct ClientState { scope: String, @@ -166,6 +221,7 @@ impl ClientState { main_origin: (u16, u16), popup_origin: Option<(u16, u16)>, cell_size: HostCellSize, + occlusion: &Occlusion, ) -> Vec { let mut bytes = self.take_pending_cleanup(); self.stale_images.sort_unstable(); @@ -198,6 +254,7 @@ impl ClientState { main_origin, popup_origin, cell_size, + occlusion, ) }) .collect::>(); @@ -550,6 +607,7 @@ fn client_host_placement( main_origin: (u16, u16), popup_origin: Option<(u16, u16)>, cell_size: HostCellSize, + occlusion: &Occlusion, ) -> Option { let origin = match (&placement.asset.source, visibility) { ( @@ -571,6 +629,9 @@ fn client_host_placement( } _ => return None, }; + if occlusion.covers(placement, origin, cell_size) { + return None; + } let source_key = HostSourceKey::ClientSurface { scope: scope.to_owned(), source: placement.asset.source.clone(), @@ -691,6 +752,182 @@ mod tests { } } + #[test] + fn occlusion_uses_final_pixel_bounds_and_strict_overlap() { + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 4], + ); + let mut placement = scene(image, 1, 2).placements.remove(0); + let mut cover = Occlusion::default(); + cover.cover(Rect::new(12, 8, 1, 1)); + // The image at (11, 7) just touches the cover's top-left corner. + assert!(!cover.covers(&placement, (10, 5), cell)); + placement.x_offset = 1; + assert!(!cover.covers(&placement, (10, 5), cell)); + placement.y_offset = 1; + assert!(cover.covers(&placement, (10, 5), cell)); + placement.x_offset = 0; + assert!(!cover.covers(&placement, (10, 5), cell)); + placement.cols = 2; + assert!(cover.covers(&placement, (10, 5), cell)); + assert!(!cover.covers(&placement, (0, 0), cell)); + cover = Occlusion::default(); + cover.cover(Rect::new(11, 7, 0, 1)); + assert!(!cover.covers(&placement, (10, 5), cell)); + } + + #[test] + fn occlusion_keeps_shared_asset_placements_and_restores_without_upload() { + for direct in [false, true] { + let mut state = ClientState::default(); + state.set_scope("occlusion"); + let _ = state.take_pending_cleanup(); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 4], + ); + let id = host_image_id("occlusion", &image.key); + let mut graphics = scene(image.clone(), 0, 0); + let mut second = graphics.placements[0].clone(); + second.logical_placement_id = 4; + second.x = 5; + graphics.placements.push(second); + if direct { + // Model an already uploaded asset, including on hosts without direct transport. + graphics.assets.clear(); + state.host.images.insert( + id, + ImageSignature { + image_width: 1, + image_height: 1, + format_code: 32, + data_len: 4, + data_fingerprint: 1, + }, + ); + } + state.set_scene(graphics); + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let _ = state.encode(Visibility::Main, (10, 5), None, cell, &Occlusion::default()); + assert_eq!(state.host.placements.len(), 2); + let mut cover = Occlusion::default(); + cover.cover(Rect::new(10, 5, 1, 1)); + let hidden = state.encode(Visibility::Main, (10, 5), None, cell, &cover); + let hidden = String::from_utf8_lossy(&hidden); + assert!(hidden.contains("a=d,d=i"), "{hidden}"); + assert!(!hidden.contains("a=d,d=I"), "{hidden}"); + assert!(hidden.contains("\u{1b}[6;16H"), "{hidden}"); + assert_eq!(state.host.placements.len(), 1); + cover.cover(Rect::new(15, 5, 1, 1)); + let _ = state.encode(Visibility::Main, (10, 5), None, cell, &cover); + assert!(state.host.placements.is_empty()); + assert!(state.host.images.contains_key(&id)); + let restored = + state.encode(Visibility::Main, (10, 5), None, cell, &Occlusion::default()); + let restored = String::from_utf8_lossy(&restored); + assert!(restored.contains("a=p"), "{restored}"); + assert!(!restored.contains("a=t"), "{restored}"); + assert_eq!(state.host.placements.len(), 2); + } + } + + #[test] + fn popup_occlusion_respects_draw_order_and_its_own_images() { + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 4], + ); + let main = scene(image, 0, 0).placements.remove(0); + let image = asset( + SurfaceGraphicsTarget::Popup { + terminal_id: "popup".into(), + }, + 2, + vec![4, 3, 2, 1], + ); + let popup = scene(image, 0, 0).placements.remove(0); + let mut cover = Occlusion::default(); + cover.cover(Rect::new(10, 5, 3, 3)); + cover.start_popup(Rect::new(9, 4, 5, 5)); + assert!(cover.covers(&main, (10, 5), cell)); + assert!(!cover.covers(&main, (0, 0), cell)); + assert!(!cover.covers(&popup, (10, 5), cell)); + cover.cover(Rect::new(10, 5, 1, 1)); + assert!(cover.covers(&popup, (10, 5), cell)); + } + + #[test] + #[ignore = "manual image placement overlap scaling profile"] + fn graphics_occlusion_render_scale_profile() { + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + for count in [1, 15] { + let mut graphics = SurfaceGraphicsScene::default(); + for index in 0..count { + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: format!("pane-{index}"), + }, + index as u64, + vec![1, 2, 3, 4], + ); + let pane = scene(image, index * 4, 0); + graphics.assets.extend(pane.assets); + graphics.placements.extend(pane.placements); + } + for covered in [false, true] { + let mut state = ClientState::default(); + state.set_scope("profile"); + state.set_scene(graphics.clone()); + let mut cover = Occlusion::default(); + if covered { + cover.cover(Rect::new(0, 10, 80, 3)); + cover.cover(Rect::new(60, 0, 20, 5)); + } + let _ = state.encode(Visibility::Main, (0, 0), None, cell, &cover); + let mut samples = Vec::new(); + for _ in 0..101 { + let start = std::time::Instant::now(); + for _ in 0..20 { + std::hint::black_box(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &cover, + )); + } + samples.push(start.elapsed().as_nanos() / 20); + } + samples.sort_unstable(); + eprintln!("graphics occlusion panes={count} disjoint_overlays={covered} median_ns={} p95_ns={}", samples[50], samples[95]); + } + } + } + #[test] fn client_encodes_final_main_origin_upload_once_and_replays_placement() { let mut state = ClientState::default(); @@ -712,6 +949,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); assert!(String::from_utf8_lossy(&first).contains("a=t,t=d")); assert!(String::from_utf8_lossy(&first).contains("\u{1b}[8;12H")); @@ -724,6 +962,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let second = String::from_utf8_lossy(&second); assert!(!second.contains("a=t,t=d")); @@ -747,12 +986,18 @@ mod tests { width_px: 8, height_px: 16, }; - let _ = state.encode(Visibility::Main, (4, 2), None, cell); + let _ = state.encode(Visibility::Main, (4, 2), None, cell, &Occlusion::default()); - let hidden = state.encode(Visibility::Hidden, (4, 2), None, cell); + let hidden = state.encode( + Visibility::Hidden, + (4, 2), + None, + cell, + &Occlusion::default(), + ); assert!(String::from_utf8_lossy(&hidden).contains("a=d,d=i")); - let restored = state.encode(Visibility::Main, (4, 2), None, cell); + let restored = state.encode(Visibility::Main, (4, 2), None, cell, &Occlusion::default()); let restored = String::from_utf8_lossy(&restored); assert!(restored.contains("a=p")); assert!(!restored.contains("a=t,t=d")); @@ -779,6 +1024,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); assert!(String::from_utf8_lossy(&bytes).contains("\u{1b}[12;33H")); } @@ -796,6 +1042,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let image = asset( SurfaceGraphicsTarget::Pane { @@ -818,6 +1065,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let bytes = String::from_utf8_lossy(&bytes); assert!(bytes.contains("a=p")); @@ -851,6 +1099,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let bytes = String::from_utf8_lossy(&bytes); assert!(bytes.contains("a=p"), "{bytes}"); @@ -883,6 +1132,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); state.set_scene(SurfaceGraphicsScene { @@ -897,6 +1147,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), )) .unwrap(); assert!(!hidden.contains(&format!("a=d,d=I,i={image_id}"))); @@ -911,6 +1162,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), )) .unwrap(); assert!(restored.contains("a=p"), "{restored}"); @@ -925,6 +1177,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), )) .unwrap(); assert!(removed.contains(&format!("a=d,d=I,i={image_id}"))); @@ -962,6 +1215,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), )) .unwrap(); assert!(bytes.contains("\u{1b}[2;3H"), "{bytes}"); @@ -1004,6 +1258,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); let bytes = String::from_utf8_lossy(&bytes); assert!(bytes.contains(&format!("a=d,d=I,i={image_id}")), "{bytes}"); @@ -1029,6 +1284,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); state.set_scope("endpoint-a:boot-2"); @@ -1062,6 +1318,7 @@ mod tests { width_px: 8, height_px: 16, }, + &Occlusion::default(), ); assert!(String::from_utf8_lossy(&bytes).contains("a=t,t=d")); } diff --git a/src/main.rs b/src/main.rs index f64df8f284..b8a4844d3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [ ]; mod agent_resume; +mod agent_view_eval; mod api; mod app; mod build_info; @@ -343,6 +344,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # distinct static glyphs for blocked, working, done, idle, and unknown states. # status_indicators = "dots" +# Accent color for highlights, borders, and navigation UI. +# Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b) +# accent = "cyan" + # Expanded agent rows. Built-ins are state_icon, state_text, machine, workspace, tab, # pane, agent, terminal_title, and terminal_title_stripped. # Custom values reported through pane metadata use a $name token. @@ -364,10 +369,6 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # row_gap = 0 # rows = [["state_icon", "workspace"], ["branch", "git_status"]] -# Accent color for highlights, borders, and navigation UI. -# Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b) -# accent = "cyan" - # Background notification popup delivery [ui.toast] # off = disable pop-up notifications @@ -435,7 +436,7 @@ pane_history = false # If the list contains no valid names, the reveal does not apply. # Accepted: pi, claude, codex, gemini, cursor, devin, cline, opencode, # copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, -# qwen-code, maki. +# qwen-code, letta, letta-code, maki. # cjk_ime_agents = [] # Cursor shape rendered when reveal_hidden_cursor_for_cjk_ime is true. # Values: block, steady_block (default), underline, steady_underline, bar, steady_bar. @@ -560,7 +561,7 @@ fn main() -> io::Result<()> { // Subcommands and flags (no TUI, no logging needed) if args.get(1).map(|s| s.as_str()) == Some("remote-client-bridge") { - return remote::run_remote_client_bridge(); + return remote::run_remote_client_bridge(&args[2..]); } if args.get(1).map(|s| s.as_str()) == Some("server") { @@ -807,6 +808,17 @@ fn main() -> io::Result<()> { mod tests { use super::*; + #[test] + fn default_config_lists_ui_accent_before_nested_tables() { + let accent_marker = "# accent = \"cyan\""; + assert_eq!(DEFAULT_CONFIG.matches(accent_marker).count(), 1); + + let accent = DEFAULT_CONFIG.find(accent_marker).unwrap(); + let sidebar = DEFAULT_CONFIG.find("# [ui.sidebar.agents]").unwrap(); + + assert!(accent < sidebar); + } + #[test] fn nested_herdr_blocks_when_env_is_set() { let config = config::Config::default(); diff --git a/src/pane.rs b/src/pane.rs index c56cfbb3ed..4ac86c2366 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1717,26 +1717,59 @@ fn resolve_shell_for_login_mode(shell: &str) -> io::Result { /// prompt would show success after a failed command (verified on 5.1). pub(crate) const WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND: &str = r"if ($null -eq $global:__HerdrOriginalPrompt) { $global:__HerdrOriginalPrompt = $function:prompt; function global:prompt { $out = @(& $global:__HerdrOriginalPrompt) -join ' '; $loc = $ExecutionContext.SessionState.Path.CurrentLocation; if ($loc.Provider.Name -eq 'FileSystem') { try { [Environment]::CurrentDirectory = $loc.ProviderPath } catch {}; $esc = [string][char]27; $out += $esc + ']9;9;' + $loc.ProviderPath + $esc + '\' }; $out } }"; +/// Login flags appended when launching POSIX-style shells on Windows. +/// +/// `portable-pty`'s default-program path resolves to `%ComSpec%` (cmd.exe) on +/// Windows and ignores the `SHELL` env override, and the Unix argv0-prefix +/// login convention does not exist there. A login shell therefore has to be +/// launched directly with the shell's own login flag. Only POSIX-style shells +/// that define one (the sh family, fish, and csh/tcsh) get it; Windows-native +/// shells such as cmd.exe and PowerShell have no login concept and launch +/// plain. +fn windows_login_shell_args(shell: &str) -> &'static [&'static str] { + let name = shell + .rsplit(['/', '\\']) + .next() + .unwrap_or(shell) + .to_ascii_lowercase(); + let name = name.strip_suffix(".exe").unwrap_or(&name); + match name { + "sh" | "bash" | "zsh" | "ksh" | "dash" | "ash" | "mksh" | "fish" | "csh" | "tcsh" => { + &["-l"] + } + _ => &[], + } +} + fn pane_shell_command_builder_for_target( shell_config: PaneShellConfig<'_>, target: ShellLaunchTarget, ) -> io::Result { let shell = pane_shell(shell_config.default_shell); - if shell_mode_uses_login_shell(shell_config.mode, target) { + // Unix login shells go through portable-pty's default-program builder so + // they receive the login argv0 convention. Windows has no such convention + // and the default-program builder would launch cmd.exe, so Windows login + // shells are built directly below like every other direct-shell target. + if shell_mode_uses_login_shell(shell_config.mode, target) + && target != ShellLaunchTarget::Windows + { let mut cmd = CommandBuilder::new_default_prog(); cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?); - Ok(cmd) - } else { - let mut cmd = CommandBuilder::new(&shell); - if uses_windows_powershell_pane_shell_for_target(shell_config, target) { - cmd.args([ - "-NoExit", - "-Command", - WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND, - ]); - } - Ok(cmd) + return Ok(cmd); } + + let mut cmd = CommandBuilder::new(&shell); + if shell_mode_uses_login_shell(shell_config.mode, target) { + cmd.args(windows_login_shell_args(&shell)); + } + if uses_windows_powershell_pane_shell_for_target(shell_config, target) { + cmd.args([ + "-NoExit", + "-Command", + WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND, + ]); + } + Ok(cmd) } fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result { @@ -1754,8 +1787,10 @@ fn uses_windows_powershell_pane_shell_for_target( shell_config: PaneShellConfig<'_>, target: ShellLaunchTarget, ) -> bool { + // Login mode no longer routes Windows through the default-program builder, + // so login PowerShell panes are launched directly and can carry the same + // prompt-based cwd reporting as non-login panes. target == ShellLaunchTarget::Windows - && !shell_mode_uses_login_shell(shell_config.mode, target) && is_powershell_shell(&pane_shell(shell_config.default_shell)) } @@ -3138,6 +3173,19 @@ impl PaneRuntime { self.terminal.visible_hyperlinks(area) } + pub(crate) fn link_regions_at( + &self, + col: u16, + row: u16, + resolve: fn(&str, usize) -> Option>, + ) -> Vec { + self.terminal.link_regions_at(col, row, resolve) + } + + pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option { + self.terminal.link_target_at(col, row) + } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { self.terminal.kitty_graphics_may_have_placements() } @@ -3808,6 +3856,122 @@ mod tests { ); } + #[test] + fn windows_login_shell_builder_launches_configured_shell_with_login_flag() { + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new("bash.exe", crate::config::ShellModeConfig::Login), + ShellLaunchTarget::Windows, + ) + .unwrap(); + + assert!( + !cmd.is_default_prog(), + "Windows login mode must not fall back to the default program (cmd.exe)" + ); + assert_eq!( + cmd.get_argv(), + &[ + std::ffi::OsString::from("bash.exe"), + std::ffi::OsString::from("-l"), + ] + ); + } + + #[test] + fn windows_login_shell_builder_launches_native_shells_without_flag() { + for shell in ["cmd.exe", "C:\\Windows\\System32\\cmd.exe"] { + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new(shell, crate::config::ShellModeConfig::Login), + ShellLaunchTarget::Windows, + ) + .unwrap(); + + assert!(!cmd.is_default_prog(), "shell {shell:?}"); + assert_eq!( + cmd.get_argv(), + &[std::ffi::OsString::from(shell)], + "shell {shell:?}" + ); + } + } + + #[test] + fn windows_login_powershell_builder_injects_prompt_cwd_shell_integration() { + for shell in [ + "powershell.exe", + "pwsh.exe", + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + ] { + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new(shell, crate::config::ShellModeConfig::Login), + ShellLaunchTarget::Windows, + ) + .unwrap(); + + assert_eq!( + cmd.get_argv(), + &[ + std::ffi::OsString::from(shell), + std::ffi::OsString::from("-NoExit"), + std::ffi::OsString::from("-Command"), + std::ffi::OsString::from(WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND), + ], + "shell {shell:?}" + ); + } + } + + #[test] + fn windows_login_shell_args_maps_posix_shells_to_login_flag() { + for shell in [ + "sh", + "sh.exe", + "bash", + "bash.exe", + "BASH.EXE", + "zsh", + "zsh.exe", + "ksh", + "dash", + "ash", + "mksh", + "fish", + "fish.exe", + "csh", + "csh.exe", + "tcsh", + "tcsh.exe", + "C:\\Program Files\\Git\\bin\\bash.exe", + ] { + assert_eq!( + windows_login_shell_args(shell), + &["-l"], + "shell {shell:?} should get the login flag" + ); + } + } + + #[test] + fn windows_login_shell_args_omits_flag_for_shells_without_login_concept() { + for shell in [ + "cmd", + "cmd.exe", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", + "nu", + "nu.exe", + "C:\\Windows\\System32\\cmd.exe", + ] { + assert_eq!( + windows_login_shell_args(shell), + &[] as &[&str], + "shell {shell:?} must not get a login flag" + ); + } + } + #[test] fn auto_shell_builder_keeps_direct_shell_on_non_macos_target() { let cmd = pane_shell_command_builder_for_target( @@ -3900,26 +4064,31 @@ mod tests { } #[test] - fn windows_powershell_pane_shell_predicate_requires_windows_and_non_login() { - let pwsh = PaneShellConfig::new("pwsh.exe", crate::config::ShellModeConfig::NonLogin); - assert!(uses_windows_powershell_pane_shell_for_target( - pwsh, - ShellLaunchTarget::Windows - )); - assert!(!uses_windows_powershell_pane_shell_for_target( - pwsh, - ShellLaunchTarget::OtherUnix - )); - assert!(!uses_windows_powershell_pane_shell_for_target( - pwsh, - ShellLaunchTarget::Macos - )); + fn windows_powershell_pane_shell_predicate_requires_windows_and_powershell() { + for mode in [ + crate::config::ShellModeConfig::NonLogin, + crate::config::ShellModeConfig::Login, + ] { + let pwsh = PaneShellConfig::new("pwsh.exe", mode); + assert!( + uses_windows_powershell_pane_shell_for_target(pwsh, ShellLaunchTarget::Windows), + "mode {mode:?}" + ); + assert!(!uses_windows_powershell_pane_shell_for_target( + pwsh, + ShellLaunchTarget::OtherUnix + )); + assert!(!uses_windows_powershell_pane_shell_for_target( + pwsh, + ShellLaunchTarget::Macos + )); + } assert!(!uses_windows_powershell_pane_shell_for_target( - PaneShellConfig::new("pwsh.exe", crate::config::ShellModeConfig::Login), + PaneShellConfig::new("cmd.exe", crate::config::ShellModeConfig::NonLogin), ShellLaunchTarget::Windows )); assert!(!uses_windows_powershell_pane_shell_for_target( - PaneShellConfig::new("cmd.exe", crate::config::ShellModeConfig::NonLogin), + PaneShellConfig::new("cmd.exe", crate::config::ShellModeConfig::Login), ShellLaunchTarget::Windows )); } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index dfd4f5369d..931d04855c 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -542,6 +542,19 @@ impl PaneTerminal { self.ghostty.visible_hyperlinks(area) } + pub(crate) fn link_regions_at( + &self, + col: u16, + row: u16, + resolve: fn(&str, usize) -> Option>, + ) -> Vec { + self.ghostty.link_regions_at(col, row, resolve) + } + + pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option { + self.ghostty.link_target_at(col, row) + } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { self.ghostty.kitty_graphics_may_have_placements() } @@ -2206,6 +2219,33 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } + pub(crate) fn link_regions_at( + &self, + col: u16, + row: u16, + resolve: fn(&str, usize) -> Option>, + ) -> Vec { + self.core + .lock() + .ok() + .and_then(|core| { + core.terminal + .viewport_link_regions(col, u32::from(row), resolve) + .ok() + }) + .unwrap_or_default() + } + + pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option { + self.core + .lock() + .ok()? + .terminal + .viewport_link_target(col, u32::from(row)) + .ok() + .flatten() + } + pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option { self.core .lock() diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index a533796fce..21d13ba08d 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -13,7 +13,13 @@ pub(crate) fn set_default_plugin_pane_pwd( ) { } -pub(crate) fn forward_remote_bridge_stdio(stream: crate::ipc::LocalStream) -> std::io::Result<()> { +#[cfg(unix)] +pub(super) const REMOTE_BRIDGE_CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC; + +pub(crate) fn forward_remote_bridge_stdio( + stream: crate::ipc::LocalStream, + _idle_timeout: bool, +) -> std::io::Result<()> { use interprocess::TryClone as _; let mut stdout = std::io::stdout().lock(); diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 29534dbee9..7ea0a2c690 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,12 +1,14 @@ use std::{ collections::{HashSet, VecDeque}, - io::Write, + io::{Read, Write}, os::fd::RawFd, path::PathBuf, process::{Command, Stdio}, sync::OnceLock, }; +pub(super) const REMOTE_BRIDGE_CLOCK: libc::clockid_t = libc::CLOCK_BOOTTIME; + use super::{ read_limited_reader, ClipboardCommand, ClipboardImage, ForegroundJob, ForegroundProcess, LimitedRead, Signal, @@ -16,13 +18,58 @@ pub(crate) use super::unix_common::{ configure_status_command, create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, remote_reattach_program, - remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported, - wait_client_stream_readable, StatusCommandGuard, + remote_ssh_config_paths, set_default_plugin_pane_pwd, shutdown_client_stream, + status_commands_supported, wait_client_stream_readable, write_client_stream, + ClientStreamReader, StatusCommandGuard, }; const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"]; const PROCESS_DETECTION_ENV_VAR: &str = "HERDR_PROCESS_DETECTION"; const CHILD_GROUPS_SCAN_LIMIT: usize = 64; +/// Upper bound on the number of processes visited while resolving a pane's +/// foreground process-group tree. Foreground-job detection reads /proc//stat +/// and task/children files for every visited process on a repeated (per-tick/5s) +/// cadence, so an unbounded walk lets accumulated descendants or unreaped zombies +/// under the pane shell grow the server's read-syscall rate and CPU without limit +/// at a constant pane count (see AGENTS.md multiplicative performance paths). The +/// foreground-group leader's subtree and the pane shell's descendants advance +/// round-robin under a shared candidate ceiling, with independent per-root work +/// budgets, so a pathologically large accumulation on either side cannot starve the +/// other. Discovery is best effort once a budget is exhausted. +const FOREGROUND_TREE_SCAN_LIMIT: usize = 512; +/// Number of `/proc//task` entries a root's subtree may consume, bounding how +/// far one process's thread count can multiply the walk's work. +const FOREGROUND_TASK_ENTRY_LIMIT: usize = 2_048; +/// Number of `/proc//task//children` bytes a root's subtree may read, +/// stopping a parent that accumulates unreaped children from growing read work +/// without limit. +const FOREGROUND_CHILD_BYTE_LIMIT: usize = 128 * 1024; +/// Aggregate number of child pids a root's subtree may parse and enqueue, bounding +/// the walk's pending queues and allocations. +const FOREGROUND_CHILD_PID_LIMIT: usize = 2_048; + +/// Per-root work budget for foreground process discovery. The foreground-group +/// leader and the pane shell each get their own budget, so one side's expansion +/// cannot exhaust the other's allowance. Every task entry, child byte, and parsed +/// child pid is charged against the owning root's budget, keeping total `/proc` work +/// bounded independently of the process-tree size and of uptime. Discovery is best +/// effort once a budget is exhausted. +#[derive(Debug)] +struct ForegroundScanBudget { + task_entries: usize, + child_bytes: usize, + child_pids: usize, +} + +impl ForegroundScanBudget { + fn for_probe() -> Self { + Self { + task_entries: FOREGROUND_TASK_ENTRY_LIMIT, + child_bytes: FOREGROUND_CHILD_BYTE_LIMIT, + child_pids: FOREGROUND_CHILD_PID_LIMIT, + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProcessDetectionMode { @@ -37,6 +84,26 @@ struct ProcGroupMember { state: char, } +pub(crate) fn launch_executable() -> std::io::Result { + use std::os::unix::ffi::OsStrExt; + + let executable = std::env::current_exe()?; + if !executable.is_file() { + // Linux marks the old inode as deleted after an update replaces the binary. + if let Some(path) = executable + .as_os_str() + .as_bytes() + .strip_suffix(b" (deleted)") + { + let replacement = PathBuf::from(std::ffi::OsStr::from_bytes(path)); + if replacement.is_file() { + return Ok(replacement); + } + } + } + Ok(executable) +} + pub fn raise_server_nofile_limit() {} pub(crate) fn should_draw_host_cursor_by_default() -> bool { @@ -221,14 +288,15 @@ fn child_groups_foreground_process_group(child_pid: u32) -> Option { fn child_groups_foreground_process_group_with( child_pid: u32, shell_group_id: u32, - mut task_ids: impl FnMut(u32) -> Vec, - mut task_children: impl FnMut(u32, u32) -> Vec, + mut task_ids: impl FnMut(u32, &mut ForegroundScanBudget) -> Vec, + mut task_children: impl FnMut(u32, u32, &mut ForegroundScanBudget) -> Vec, mut process_group_id: impl FnMut(u32) -> Option, ) -> Option { + let mut budget = ForegroundScanBudget::for_probe(); let mut newest = None; let mut scanned = 0usize; - for tid in task_ids(child_pid) { - for child in task_children(child_pid, tid) { + for tid in task_ids(child_pid, &mut budget) { + for child in task_children(child_pid, tid, &mut budget) { if scanned >= CHILD_GROUPS_SCAN_LIMIT { return None; } @@ -266,11 +334,14 @@ fn foreground_process_group_members( fn foreground_process_group_members_with( child_pid: u32, process_group_id: u32, - task_ids: impl FnMut(u32) -> Vec, - task_children: impl FnMut(u32, u32) -> Vec, + task_ids: impl FnMut(u32, &mut ForegroundScanBudget) -> Vec, + task_children: impl FnMut(u32, u32, &mut ForegroundScanBudget) -> Vec, mut live_member: impl FnMut(u32, u32) -> Option, ) -> Option> { - let mut members = process_tree_pids([child_pid, process_group_id], task_ids, task_children) + // The leader is passed first; `process_tree_pids` advances both roots round-robin + // so a truncated scan cannot let the pane shell's unrelated descendants starve + // the foreground group, or vice versa. + let mut members = process_tree_pids([process_group_id, child_pid], task_ids, task_children) .into_iter() .filter_map(|pid| live_member(process_group_id, pid)) .collect::>(); @@ -280,49 +351,132 @@ fn foreground_process_group_members_with( fn process_tree_pids( roots: impl IntoIterator, - mut task_ids: impl FnMut(u32) -> Vec, - mut task_children: impl FnMut(u32, u32) -> Vec, + mut task_ids: impl FnMut(u32, &mut ForegroundScanBudget) -> Vec, + mut task_children: impl FnMut(u32, u32, &mut ForegroundScanBudget) -> Vec, ) -> Vec { - let mut pending = VecDeque::new(); let mut visited = HashSet::new(); - for pid in roots { - if pid > 0 && visited.insert(pid) { - pending.push_back(pid); + let mut pids = Vec::new(); + // Keep one breadth-first frontier with its own work budget per root, so a large + // expansion on one side cannot consume the other side's allowance. Frontier turns + // advance round-robin, sharing the candidate ceiling between the foreground-group + // leader's subtree and the pane shell's descendants. + struct Frontier { + pending: VecDeque, + budget: ForegroundScanBudget, + } + let mut frontiers: Vec = Vec::new(); + for root in roots { + if root > 0 && visited.insert(root) { + frontiers.push(Frontier { + pending: VecDeque::from([root]), + budget: ForegroundScanBudget::for_probe(), + }); } } - let mut pids = Vec::new(); - while let Some(pid) = pending.pop_front() { - pids.push(pid); - for tid in task_ids(pid) { - for child_pid in task_children(pid, tid) { - if child_pid > 0 && visited.insert(child_pid) { - pending.push_back(child_pid); + loop { + let mut progressed = false; + for frontier in &mut frontiers { + if pids.len() >= FOREGROUND_TREE_SCAN_LIMIT { + return pids; + } + let Some(pid) = frontier.pending.pop_front() else { + continue; + }; + progressed = true; + pids.push(pid); + for tid in task_ids(pid, &mut frontier.budget) { + for child_pid in task_children(pid, tid, &mut frontier.budget) { + if child_pid > 0 && visited.insert(child_pid) { + frontier.pending.push_back(child_pid); + } } } } + if !progressed { + return pids; + } } - pids } -fn process_task_ids(pid: u32) -> Vec { - std::fs::read_dir(format!("/proc/{pid}/task")) - .into_iter() - .flatten() - .flatten() - .filter_map(|entry| numeric_file_name(&entry)) - .collect() +fn process_task_ids(pid: u32, budget: &mut ForegroundScanBudget) -> Vec { + let mut ids = Vec::new(); + let Ok(entries) = std::fs::read_dir(format!("/proc/{pid}/task")) else { + return ids; + }; + for entry in entries.flatten() { + if budget.task_entries == 0 { + break; + } + budget.task_entries -= 1; + if let Some(tid) = numeric_file_name(&entry) { + ids.push(tid); + } + } + ids } -fn process_task_children(pid: u32, tid: u32) -> Vec { - let Some(children) = std::fs::read_to_string(format!("/proc/{pid}/task/{tid}/children")).ok() - else { +fn process_task_children(pid: u32, tid: u32, budget: &mut ForegroundScanBudget) -> Vec { + if budget.child_bytes == 0 || budget.child_pids == 0 { + return Vec::new(); + } + let Ok(file) = std::fs::File::open(format!("/proc/{pid}/task/{tid}/children")) else { return Vec::new(); }; - children - .split_whitespace() - .filter_map(|child| child.parse::().ok()) - .collect() + read_bounded_pid_list(file, budget) +} + +/// Read a whitespace-separated pid list, charging the shared budget for every byte +/// read and every parsed pid. A token cut off by the byte budget is discarded so a +/// partial value is never parsed as a different pid; the final token is only kept +/// when the reader reaches end-of-file. +fn read_bounded_pid_list(mut reader: impl Read, budget: &mut ForegroundScanBudget) -> Vec { + let mut pids = Vec::new(); + let mut token = Vec::new(); + let mut buffer = [0_u8; 4096]; + + while budget.child_bytes > 0 && budget.child_pids > 0 { + let read_len = budget.child_bytes.min(buffer.len()); + let bytes_read = match reader.read(&mut buffer[..read_len]) { + Ok(0) => { + push_pid_token(&mut pids, &mut token, budget); + return pids; + } + Ok(bytes_read) => bytes_read, + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => return pids, + }; + budget.child_bytes -= bytes_read; + for &byte in &buffer[..bytes_read] { + if byte.is_ascii_digit() { + token.push(byte); + continue; + } + push_pid_token(&mut pids, &mut token, budget); + if budget.child_pids == 0 { + return pids; + } + } + } + + // The byte budget ran out mid-stream: drop the trailing token in case the read + // truncated it. + pids +} + +fn push_pid_token(pids: &mut Vec, token: &mut Vec, budget: &mut ForegroundScanBudget) { + if token.is_empty() || budget.child_pids == 0 { + token.clear(); + return; + } + if let Some(pid) = std::str::from_utf8(token) + .ok() + .and_then(|text| text.parse::().ok()) + { + budget.child_pids -= 1; + pids.push(pid); + } + token.clear(); } fn numeric_file_name(entry: &std::fs::DirEntry) -> Option { @@ -919,8 +1073,8 @@ mod tests { let group = child_groups_foreground_process_group_with( 100, 100, - |pid| tasks.get(&pid).cloned().unwrap_or_default(), - |pid, tid| children.get(&(pid, tid)).cloned().unwrap_or_default(), + |pid, _budget| tasks.get(&pid).cloned().unwrap_or_default(), + |pid, tid, _budget| children.get(&(pid, tid)).cloned().unwrap_or_default(), |pid| groups.get(&pid).copied(), ); @@ -936,8 +1090,8 @@ mod tests { let group = child_groups_foreground_process_group_with( 100, 90, - |pid| tasks.get(&pid).cloned().unwrap_or_default(), - |pid, tid| children.get(&(pid, tid)).cloned().unwrap_or_default(), + |pid, _budget| tasks.get(&pid).cloned().unwrap_or_default(), + |pid, tid, _budget| children.get(&(pid, tid)).cloned().unwrap_or_default(), |pid| groups.get(&pid).copied(), ); @@ -953,8 +1107,8 @@ mod tests { let group = child_groups_foreground_process_group_with( 100, 90, - |pid| tasks.get(&pid).cloned().unwrap_or_default(), - |pid, tid| children.get(&(pid, tid)).cloned().unwrap_or_default(), + |pid, _budget| tasks.get(&pid).cloned().unwrap_or_default(), + |pid, tid, _budget| children.get(&(pid, tid)).cloned().unwrap_or_default(), |pid| groups.get(&pid).copied(), ); @@ -969,8 +1123,8 @@ mod tests { let group = child_groups_foreground_process_group_with( 100, 100, - |_| vec![100], - |_, _| children.clone(), + |_, _budget| vec![100], + |_, _, _budget| children.clone(), |pid| { inspected += 1; Some(pid as i32) @@ -1015,11 +1169,11 @@ mod tests { let members = foreground_process_group_members_with( 100, 200, - |pid| { + |pid, _budget| { task_reads.borrow_mut().push(pid); tasks.get(&pid).cloned().unwrap_or_default() }, - |pid, tid| { + |pid, tid, _budget| { child_reads.borrow_mut().push((pid, tid)); children.get(&(pid, tid)).cloned().unwrap_or_default() }, @@ -1053,13 +1207,172 @@ mod tests { assert!(!member_reads.borrow().contains(&9999)); } + #[test] + fn foreground_tree_traversal_is_bounded_by_the_scan_limit() { + // A pane shell whose descendant tree is far larger than the bound (long-lived + // agents accumulating children or unreaped zombies) must not make foreground + // detection read /proc//stat for an unbounded number of processes, and + // the foreground group's own subtree must win the limited scan budget. + let child_count = FOREGROUND_TREE_SCAN_LIMIT + 200; + let shell_children: Vec = (10..10 + child_count as u32).collect(); + // The leader's subtree: leader(2) -> agent(9000) -> agent-child(9001). Both + // descendants sit behind the shell's backlog and must still be reached. + let agent_pid = 9000u32; + let agent_child_pid = 9001u32; + let stat_reads = RefCell::new(Vec::new()); + + let members = foreground_process_group_members_with( + 1, + 2, + // Every pid has its own single task. + |pid, _budget| vec![pid], + |pid, _tid, _budget| match pid { + // The shell exposes the whole huge unrelated child list. + 1 => shell_children.clone(), + // The leader exposes its agent child, which exposes its own child. + 2 => vec![agent_pid], + _ if pid == agent_pid => vec![agent_child_pid], + _ => Vec::new(), + }, + |process_group_id, pid| { + // Every visited pid triggers a /proc//stat read; count them. + stat_reads.borrow_mut().push(pid); + (process_group_id == 2).then(|| ProcGroupMember { + pid, + comm: format!("p{pid}"), + state: 'S', + }) + }, + ) + .unwrap(); + + // Bounded: foreground detection inspects at most the scan limit processes, + // regardless of how large the descendant tree has grown. + assert!( + stat_reads.borrow().len() <= FOREGROUND_TREE_SCAN_LIMIT, + "foreground traversal read /proc/stat for {} processes, exceeding the {} bound", + stat_reads.borrow().len(), + FOREGROUND_TREE_SCAN_LIMIT + ); + assert!(members.len() <= FOREGROUND_TREE_SCAN_LIMIT); + // The foreground-group leader and its descendants are visited before the + // shell's unrelated backlog, so the detected agent survives truncation. + assert!( + members.iter().any(|member| member.pid == 2), + "group leader must survive truncation" + ); + assert!( + members.iter().any(|member| member.pid == agent_child_pid), + "leader descendants must be visited before unrelated shell descendants" + ); + } + + #[test] + fn foreground_tree_traversal_shares_the_scan_limit_between_roots() { + // A foreground-group leader with more descendants than the scan limit must not + // starve the pane shell's own foreground-group children, such as pipeline + // members that live under the shell rather than under the leader. + let leader_children: Vec = + (100..100 + FOREGROUND_TREE_SCAN_LIMIT as u32 + 200).collect(); + let pipeline_pid = 9000u32; + let stat_reads = RefCell::new(Vec::new()); + + let members = foreground_process_group_members_with( + 1, + 2, + |pid, _budget| vec![pid], + |pid, _tid, _budget| match pid { + // The shell exposes a foreground-group pipeline child... + 1 => vec![pipeline_pid], + // ...while the leader's own subtree already exceeds the bound. + 2 => leader_children.clone(), + _ => Vec::new(), + }, + |process_group_id, pid| { + stat_reads.borrow_mut().push(pid); + (process_group_id == 2).then(|| ProcGroupMember { + pid, + comm: format!("p{pid}"), + state: 'S', + }) + }, + ) + .unwrap(); + + assert!(stat_reads.borrow().len() <= FOREGROUND_TREE_SCAN_LIMIT); + assert!( + members.iter().any(|member| member.pid == pipeline_pid), + "shell-side foreground members must survive an oversized leader subtree" + ); + } + + #[test] + fn bounded_child_list_read_keeps_complete_tokens_at_eof() { + let mut budget = ForegroundScanBudget::for_probe(); + let pids = read_bounded_pid_list(std::io::Cursor::new(b"10 20 30"), &mut budget); + assert_eq!(pids, vec![10, 20, 30]); + assert!(budget.child_bytes < FOREGROUND_CHILD_BYTE_LIMIT); + } + + #[test] + fn bounded_child_list_read_drops_a_token_cut_off_by_the_byte_budget() { + let mut budget = ForegroundScanBudget::for_probe(); + // The byte budget ends inside the trailing pid, which must not parse as 3. + budget.child_bytes = 8; + let pids = read_bounded_pid_list(std::io::Cursor::new(b" 10 20 300"), &mut budget); + assert_eq!(pids, vec![10, 20]); + assert_eq!(budget.child_bytes, 0); + } + + #[test] + fn bounded_child_list_read_stops_at_the_pid_budget() { + let mut budget = ForegroundScanBudget::for_probe(); + budget.child_pids = 2; + let pids = read_bounded_pid_list(std::io::Cursor::new(b"10 20 30 40"), &mut budget); + assert_eq!(pids, vec![10, 20]); + assert_eq!(budget.child_pids, 0); + } + + #[test] + fn foreground_tree_traversal_reserves_enumeration_budget_per_root() { + // The leader expansion spending its whole budget must not stop the shell root + // from enumerating its own children. + let leader_consumed = RefCell::new(false); + let shell_saw_budget = RefCell::new(false); + + let members = foreground_process_group_members_with( + 1, + 2, + |pid, budget| { + if pid == 2 { + *leader_consumed.borrow_mut() = true; + } else if pid == 1 { + *shell_saw_budget.borrow_mut() = budget.task_entries > 0; + } + budget.task_entries = 0; + budget.child_bytes = 0; + budget.child_pids = 0; + vec![pid] + }, + |_pid, _tid, _budget| Vec::new(), + |_process_group_id, _pid| None, + ); + + assert!(*leader_consumed.borrow()); + assert!( + *shell_saw_budget.borrow(), + "shell root must keep its own budget after the leader spends its own" + ); + assert!(members.is_none()); + } + #[test] fn foreground_members_degrade_to_the_direct_group_leader() { let members = foreground_process_group_members_with( 100, 200, - |_| Vec::new(), - |_, _| Vec::new(), + |_, _budget| Vec::new(), + |_, _, _budget| Vec::new(), |process_group_id, pid| { (pid == process_group_id).then(|| ProcGroupMember { pid, @@ -1087,8 +1400,8 @@ mod tests { foreground_process_group_members_with( 100, 200, - |pid| vec![pid], - |pid, tid| { + |pid, _budget| vec![pid], + |pid, tid, _budget| { children .borrow() .get(&(pid, tid)) diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 8840c7ed9b..4e86deab1b 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -7,6 +7,8 @@ use std::process::{Command, Stdio}; use std::ptr::NonNull; use std::sync::OnceLock; +pub(super) const REMOTE_BRIDGE_CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC; + use super::{ read_limited_reader, ClipboardCommand, ClipboardImage, ForegroundJob, ForegroundProcess, LimitedRead, Signal, @@ -16,8 +18,9 @@ pub(crate) use super::unix_common::{ configure_status_command, create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, remote_reattach_program, - remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported, - wait_client_stream_readable, StatusCommandGuard, + remote_ssh_config_paths, set_default_plugin_pane_pwd, shutdown_client_stream, + status_commands_supported, wait_client_stream_readable, write_client_stream, + ClientStreamReader, StatusCommandGuard, }; const PROC_PGRP_ONLY: u32 = 2; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 67e474ff2e..a578b42dfc 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -55,6 +55,11 @@ pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildEx ChildExitReason::Exited } +#[cfg(not(target_os = "linux"))] +pub(crate) fn launch_executable() -> std::io::Result { + std::env::current_exe() +} + pub(crate) fn detached_custom_command_process(command: &str) -> std::process::Command { let mut process = detached_custom_command_process_platform(command); configure_background_command(&mut process); @@ -261,6 +266,13 @@ pub(crate) struct RemoteSshConfigPaths { pub(crate) multiplexing: bool, } +pub(crate) const REMOTE_BRIDGE_IDLE_TIMEOUT_SUPPORTED: bool = + cfg!(any(target_os = "linux", target_os = "macos")); + +#[cfg(unix)] +mod remote_bridge; +#[cfg(all(test, unix))] +mod remote_bridge_tests; #[cfg(unix)] mod unix_common; #[cfg(unix)] diff --git a/src/platform/remote_bridge.rs b/src/platform/remote_bridge.rs new file mode 100644 index 0000000000..a8db4d290e --- /dev/null +++ b/src/platform/remote_bridge.rs @@ -0,0 +1,135 @@ +use std::io::{self, Read, Write}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + mpsc, Arc, +}; +use std::time::Duration; + +pub(crate) const IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +fn now() -> io::Result { + let mut time = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // This clock includes suspend, so short maintenance wakes can reap old bridges. + if unsafe { libc::clock_gettime(super::REMOTE_BRIDGE_CLOCK, &mut time) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok((time.tv_sec as u64) * 1_000_000_000 + time.tv_nsec as u64) +} + +#[derive(Clone)] +pub(super) struct Activity { + last: Arc, + _stop: mpsc::Sender<()>, +} + +impl Activity { + pub(super) fn start(timeout: Duration) -> io::Result { + let last = Arc::new(AtomicU64::new(now()?)); + let watched = Arc::clone(&last); + let (stop, stopped) = mpsc::channel(); + std::thread::Builder::new() + .name("ssh-bridge-liveness".into()) + .spawn(move || { + loop { + match stopped.recv_timeout(timeout.min(Duration::from_secs(1))) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + let expired = now().map_or(true, |now| idle_expired(&watched, now, timeout)); + if expired { + // Only the dedicated bridge process owns this watchdog. Returning from a + // blocked copy cannot guarantee shutdown, and joining it could hang forever. + std::process::exit(1); + } + } + })?; + Ok(Self { last, _stop: stop }) + } + + fn record(&self) -> io::Result<()> { + self.last.fetch_max(now()?, Ordering::Relaxed); + Ok(()) + } +} + +fn idle_expired(last: &AtomicU64, now: u64, timeout: Duration) -> bool { + Duration::from_nanos(now.saturating_sub(last.load(Ordering::Relaxed))) >= timeout +} + +pub(super) struct TrackedIo { + inner: T, + activity: Option, +} + +impl TrackedIo { + pub(super) fn new(inner: T, activity: Option) -> Self { + Self { inner, activity } + } + + fn progressed(&self, count: usize) -> io::Result<()> { + if count > 0 { + if let Some(activity) = &self.activity { + activity.record()?; + } + } + Ok(()) + } +} + +impl Read for TrackedIo { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let count = self.inner.read(buffer)?; + self.progressed(count)?; + Ok(count) + } +} + +impl Write for TrackedIo { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let count = self.inner.write(buffer)?; + self.progressed(count)?; + Ok(count) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_deadline_counts_elapsed_sleep_and_only_positive_io_renews_it() { + let (stop, _stopped) = mpsc::channel(); + let last = Arc::new(AtomicU64::new(0)); + let activity = Activity { + last: Arc::clone(&last), + _stop: stop, + }; + assert!(idle_expired( + &last, + IDLE_TIMEOUT.as_nanos() as u64, + IDLE_TIMEOUT + )); + let mut reader = TrackedIo::new(&b"output"[..], Some(activity.clone())); + reader.read_exact(&mut [0; 6]).unwrap(); + let after_read = last.load(Ordering::Relaxed); + assert!(after_read > 0); + assert!(!idle_expired(&last, after_read, IDLE_TIMEOUT)); + assert_eq!(reader.read(&mut [0; 1]).unwrap(), 0); + assert_eq!(last.load(Ordering::Relaxed), after_read); + let mut writer = TrackedIo::new(Vec::new(), Some(activity)); + writer.write_all(b"ping").unwrap(); + assert!(last.load(Ordering::Relaxed) >= after_read); + assert!(idle_expired( + &last, + last.load(Ordering::Relaxed) + 120_000_000_000, + IDLE_TIMEOUT + )); + } +} diff --git a/src/platform/remote_bridge_tests.rs b/src/platform/remote_bridge_tests.rs new file mode 100644 index 0000000000..ef4aade844 --- /dev/null +++ b/src/platform/remote_bridge_tests.rs @@ -0,0 +1,168 @@ +use std::io::{Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +const TIMEOUT: Duration = Duration::from_millis(300); + +#[test] +fn bridge_child() { + let Some(path) = std::env::var_os("HERDR_BRIDGE_TEST_SOCKET") else { + return; + }; + let stream = crate::ipc::connect_local_stream(&PathBuf::from(path)).unwrap(); + let timeout = (std::env::var_os("HERDR_BRIDGE_TEST_LEGACY").is_none()).then_some(TIMEOUT); + super::unix_common::forward_remote_bridge_stdio_with_timeout(stream, timeout).unwrap(); +} + +struct Bridge { + child: Child, + stream: UnixStream, + path: PathBuf, +} + +impl Bridge { + fn start(legacy: bool) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "hbl-{}-{}.sock", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let listener = UnixListener::bind(&path).unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "platform::remote_bridge_tests::bridge_child", + "--nocapture", + ]) + .env("HERDR_BRIDGE_TEST_SOCKET", &path) + .env_remove("HERDR_BRIDGE_TEST_LEGACY") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + if legacy { + command.env("HERDR_BRIDGE_TEST_LEGACY", "1"); + } + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_file(&path); + panic!("bridge did not connect: {error}"); + } + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_millis(100))) + .unwrap(); + Self { + child, + stream, + path, + } + } + + fn wait(&mut self) -> ExitStatus { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(status) = self.child.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "bridge did not exit"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn finish(&mut self) -> String { + drop(self.child.stdin.take()); + let mut input = Vec::new(); + self.stream.read_to_end(&mut input).unwrap(); + self.stream + .write_all(b"final-output-after-stdin-eof") + .unwrap(); + self.stream.shutdown(std::net::Shutdown::Write).unwrap(); + assert!(self.wait().success()); + let mut output = String::new(); + self.child + .stdout + .take() + .unwrap() + .read_to_string(&mut output) + .unwrap(); + output + } +} + +impl Drop for Bridge { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.path); + } +} + +#[test] +fn bridge_expires_when_silent_or_stdout_is_blocked() { + for blocked in [false, true] { + let mut bridge = Bridge::start(false); + if blocked { + let buffer = vec![b'x'; 64 * 1024]; + let deadline = Instant::now() + Duration::from_secs(2); + while bridge.stream.write_all(&buffer).is_ok() { + assert!(Instant::now() < deadline, "failed to fill bridge stdout"); + } + } + assert_eq!(bridge.wait().code(), Some(1)); + } +} + +#[test] +fn bridge_preserves_one_way_progress_and_drains_after_stdin_eof() { + for upload in [false, true] { + let mut bridge = Bridge::start(false); + for _ in 0..12 { + if upload { + bridge + .child + .stdin + .as_mut() + .unwrap() + .write_all(b"ping") + .unwrap(); + bridge.stream.read_exact(&mut [0; 4]).unwrap(); + } else { + bridge.stream.write_all(b"output").unwrap(); + } + std::thread::sleep(Duration::from_millis(60)); + assert!(bridge.child.try_wait().unwrap().is_none()); + } + assert!(bridge.finish().contains("final-output-after-stdin-eof")); + } +} + +#[test] +fn legacy_bridge_has_no_idle_deadline() { + let mut bridge = Bridge::start(true); + std::thread::sleep(TIMEOUT * 2); + assert!(bridge.child.try_wait().unwrap().is_none()); + assert!(bridge.finish().contains("final-output-after-stdin-eof")); +} diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index 756abc174a..a937b86360 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -8,6 +8,100 @@ pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::C } } +pub(crate) fn shutdown_client_stream(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { + let crate::ipc::LocalStream::UdSocket(stream) = stream; + stream.inner().shutdown(std::net::Shutdown::Both) +} + +pub(crate) struct ClientStreamReader<'a>(pub(crate) &'a mut crate::ipc::LocalStream); + +impl std::io::Read for ClientStreamReader<'_> { + fn read(&mut self, data: &mut [u8]) -> std::io::Result { + use std::os::fd::AsRawFd as _; + + loop { + match self.0.read(data) { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + let crate::ipc::LocalStream::UdSocket(stream) = &*self.0; + let mut descriptor = libc::pollfd { + fd: stream.inner().as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // Sleep until input or shutdown, without polling quiet observers. + if unsafe { libc::poll(&mut descriptor, 1, -1) } < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error); + } + } + } + result => return result, + } + } + } +} + +pub(crate) fn write_client_stream( + stream: &crate::ipc::LocalStream, + mut data: &[u8], +) -> std::io::Result<()> { + use std::io::{self, Write as _}; + use std::os::fd::AsRawFd as _; + use std::time::Instant; + + let crate::ipc::LocalStream::UdSocket(socket) = stream; + let mut socket = socket.inner(); + let Some(timeout) = socket.write_timeout()? else { + return socket.write_all(data); + }; + let timed_out = || { + // Dropping the writer clone alone would leave the reader blocked. + let _ = shutdown_client_stream(stream); + io::Error::new( + io::ErrorKind::TimedOut, + "terminal observer stopped receiving output", + ) + }; + let mut progress = Instant::now(); + while !data.is_empty() { + match socket.write(data) { + Ok(0) => return Err(io::ErrorKind::WriteZero.into()), + Ok(written) => { + data = &data[written..]; + progress = Instant::now(); + continue; + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) => {} + Err(error) => return Err(error), + } + let remaining = timeout + .checked_sub(progress.elapsed()) + .ok_or_else(timed_out)?; + let mut descriptor = libc::pollfd { + fd: socket.as_raw_fd(), + events: libc::POLLOUT, + revents: 0, + }; + let wait_ms = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; + let ready = unsafe { libc::poll(&mut descriptor, 1, wait_ms) }; + if ready == 0 { + return Err(timed_out()); + } + if ready < 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } + } + Ok(()) +} + pub(crate) fn wait_client_stream_readable(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { use std::os::fd::{AsFd as _, AsRawFd as _}; let crate::ipc::LocalStream::UdSocket(stream) = stream; @@ -27,15 +121,33 @@ pub(crate) fn wait_client_stream_readable(stream: &crate::ipc::LocalStream) -> s Ok(()) } -pub(crate) fn forward_remote_bridge_stdio(stream: crate::ipc::LocalStream) -> std::io::Result<()> { +pub(crate) fn forward_remote_bridge_stdio( + stream: crate::ipc::LocalStream, + idle_timeout: bool, +) -> std::io::Result<()> { + forward_remote_bridge_stdio_with_timeout( + stream, + idle_timeout.then_some(super::remote_bridge::IDLE_TIMEOUT), + ) +} + +pub(super) fn forward_remote_bridge_stdio_with_timeout( + stream: crate::ipc::LocalStream, + idle_timeout: Option, +) -> std::io::Result<()> { + use super::remote_bridge::{Activity, TrackedIo}; use interprocess::TryClone as _; - let mut stdout = std::io::stdout().lock(); - let mut socket_to_stdout = stream.try_clone()?; + let activity = idle_timeout.map(Activity::start).transpose()?; + let mut stdout = TrackedIo::new(std::io::stdout().lock(), activity.clone()); + let mut socket_to_stdout = TrackedIo::new(stream.try_clone()?, activity.clone()); let mut stdin_to_socket = stream; let _upload = std::thread::spawn(move || { - let mut stdin = std::io::stdin(); - let _ = copy_flush(&mut stdin, &mut stdin_to_socket); + let mut stdin = TrackedIo::new(std::io::stdin(), activity.clone()); + let _ = copy_flush( + &mut stdin, + &mut TrackedIo::new(&mut stdin_to_socket, activity), + ); let crate::ipc::LocalStream::UdSocket(stream) = stdin_to_socket; let _ = stream.inner().shutdown(std::net::Shutdown::Write); }); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 8344be2dc2..879778cc05 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -52,7 +52,10 @@ pub(crate) fn wait_client_stream_readable( Ok(()) } -pub(crate) fn forward_remote_bridge_stdio(stream: crate::ipc::LocalStream) -> std::io::Result<()> { +pub(crate) fn forward_remote_bridge_stdio( + stream: crate::ipc::LocalStream, + _idle_timeout: bool, +) -> std::io::Result<()> { use interprocess::TryClone as _; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index 5c2e6ac693..d28322b52c 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -49,6 +49,9 @@ pub fn terminal_size_control(cols: u16, rows: u16) -> super::ClientMessage { } } +pub const AGENT_VIEW_PROJECTION_CAPABILITY: &str = "agent_view_projection"; +pub const AGENT_VIEW_PROJECTION_KIND: &str = "endpoint.agent-view.v1"; + fn default_true() -> bool { true } @@ -87,6 +90,16 @@ pub struct EndpointHandshakeError { pub message: String, } +/// Optional companion to a V1 snapshot. The view payload is intentionally opaque to this +/// stable envelope so clients can ignore view language additions they do not understand. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointAgentViewProjection { + pub boot_id: String, + pub revision: u64, + #[serde(default)] + pub view: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EndpointServerWelcome { pub generation: u32, @@ -110,6 +123,22 @@ pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result, +) -> serde_json::Result { + let projection = EndpointAgentViewProjection { + boot_id: boot_id.to_owned(), + revision, + view: view.map(serde_json::to_value).transpose()?, + }; + Ok(ServerMessage::EndpointControl { + kind: AGENT_VIEW_PROJECTION_KIND.into(), + data: serde_json::to_string(&projection)?, + }) +} + impl EndpointClientHello { pub fn supports_required_codecs(&self) -> bool { self.snapshot_codecs @@ -141,6 +170,7 @@ impl EndpointServerWelcome { SURFACE_INTEREST_CAPABILITY.into(), PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), HEALTH_CHECK_CAPABILITY.into(), + AGENT_VIEW_PROJECTION_CAPABILITY.into(), ], error: None, } @@ -271,6 +301,33 @@ mod tests { assert_eq!(decoded, snapshot); } + #[test] + fn agent_view_projection_is_an_optional_revision_bound_control() { + let view = crate::api::schema::AgentViewSetParams { + source: "example.views".into(), + label: Some("focus".into()), + filter: None, + sort: Vec::new(), + }; + let ServerMessage::EndpointControl { kind, data } = + agent_view_projection_message("boot", 7, Some(&view)).unwrap() + else { + panic!("projection should use endpoint control"); + }; + assert_eq!(kind, AGENT_VIEW_PROJECTION_KIND); + let projection: EndpointAgentViewProjection = serde_json::from_str(&data).unwrap(); + assert_eq!(projection.boot_id, "boot"); + assert_eq!(projection.revision, 7); + assert_eq!( + projection + .view + .map(serde_json::from_value) + .transpose() + .unwrap(), + Some(view) + ); + } + #[test] fn snapshot_json_tolerates_future_fields_and_command_actions() { let mut snapshot = match snapshot_message(&snapshot()).unwrap() { @@ -361,6 +418,7 @@ mod tests { SURFACE_INTEREST_CAPABILITY.to_string(), PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(), HEALTH_CHECK_CAPABILITY.to_string(), + AGENT_VIEW_PROJECTION_CAPABILITY.to_string(), ] ); } diff --git a/src/raw_input.rs b/src/raw_input.rs index e13d913371..147d6e3921 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -88,7 +88,7 @@ impl RawInputFramer { Self::events_from_chunks(self.byte_framer.push(data)) } - #[cfg(any(windows, all(test, not(target_os = "macos"))))] + #[cfg(any(windows, test))] pub(crate) fn has_pending_input(&self) -> bool { self.byte_framer.has_pending_input() } @@ -107,6 +107,15 @@ impl RawInputFramer { Self::events_from_chunks(self.byte_framer.flush_timeout()) } + /// Semantic input ends mouse recovery, unlike another idle interval. + #[cfg(any(windows, test))] + pub(crate) fn flush_interrupted(&mut self) -> Vec { + let mut chunks = self.byte_framer.flush_timeout(); + self.byte_framer.timed_out_mouse_prefix = None; + chunks.extend(self.byte_framer.drain_available_chunks()); + Self::events_from_chunks(chunks) + } + fn events_from_chunks(chunks: Vec>) -> Vec { chunks .into_iter() @@ -131,6 +140,8 @@ pub(crate) struct RawInputByteFramer { buffer: Vec, discard_until: Option, discarded_tail_bytes: usize, + // Keep the discarded prefix separate from continuation bytes awaiting validation. + timed_out_mouse_prefix: Option>, lone_escape_recently_flushed: bool, host_color_replies_awaited: u16, host_cell_size_replies_awaited: u16, @@ -227,17 +238,16 @@ impl RawInputByteFramer { pub(crate) fn flush_timeout(&mut self) -> Vec> { let mut chunks = self.drain_available_chunks(); + // Idle is not evidence that a mouse report has ended. The continuation + // stays bounded and is released if it cannot complete a valid report. + if self.timed_out_mouse_prefix.is_some() { + return chunks; + } + if let Some(family) = self.discard_until { if family == ControlStringFamily::HostReplyCsi { return chunks; } - if family == ControlStringFamily::OrphanedSgrMouseTail { - self.buffer.clear(); - self.discard_until = None; - self.discarded_tail_bytes = 0; - return chunks; - } - let keep_split_st = self.buffer.last() == Some(&ESC); let keep_discarding = plausible_control_string_tail(family, &self.buffer); self.discarded_tail_bytes = self.discarded_tail_bytes.saturating_add(self.buffer.len()); @@ -262,11 +272,9 @@ impl RawInputByteFramer { len = self.buffer.len(), "discarding incomplete orphaned SGR mouse tail after input timeout" ); - discard_or_buffer_orphaned_sgr_mouse_tail( - &mut self.buffer, - &mut self.discard_until, - &mut self.discarded_tail_bytes, - ); + let mut prefix = vec![ESC]; + prefix.append(&mut self.buffer); + self.retain_timed_out_mouse_prefix(prefix); self.lone_escape_recently_flushed = false; return chunks; } @@ -276,10 +284,8 @@ impl RawInputByteFramer { bytes = ?self.buffer, "discarding incomplete SGR mouse sequence after input timeout" ); - self.discarded_tail_bytes = self.buffer.len(); - self.discard_until = (self.discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES) - .then_some(ControlStringFamily::OrphanedSgrMouseTail); - self.buffer.clear(); + let prefix = std::mem::take(&mut self.buffer); + self.retain_timed_out_mouse_prefix(prefix); return chunks; } @@ -407,10 +413,27 @@ impl RawInputByteFramer { chunks } + fn retain_timed_out_mouse_prefix(&mut self, prefix: Vec) { + self.timed_out_mouse_prefix = (prefix.len() < MAX_DISCARDED_CONTROL_TAIL_BYTES + && plausible_sgr_mouse_prefix(&prefix)) + .then_some(prefix); + } + fn drain_available_chunks(&mut self) -> Vec> { let mut chunks = Vec::new(); loop { + if let Some(prefix) = &self.timed_out_mouse_prefix { + match classify_sgr_mouse_continuation(prefix, &self.buffer) { + SgrMouseContinuation::Incomplete => break, + SgrMouseContinuation::Complete(len) => { + self.buffer.drain(..len); + } + SgrMouseContinuation::Invalid => {} + } + self.timed_out_mouse_prefix = None; + } + if self.lone_escape_recently_flushed { if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) { break; @@ -432,18 +455,6 @@ impl RawInputByteFramer { } break; } - if family == ControlStringFamily::OrphanedSgrMouseTail { - if discard_orphaned_sgr_mouse_tail( - &mut self.buffer, - &mut self.discarded_tail_bytes, - ) { - self.discard_until = None; - self.discarded_tail_bytes = 0; - continue; - } - break; - } - let Some(terminator_len) = control_string_terminator_for_family(&self.buffer, family) else { @@ -518,9 +529,6 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> }), ControlStringFamily::StTerminated => buffer.last() == Some(&ESC), ControlStringFamily::HostReplyCsi => false, - ControlStringFamily::OrphanedSgrMouseTail => buffer - .iter() - .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')), } } @@ -535,6 +543,13 @@ pub(crate) fn events_require_host_surface_redraw( .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) } +#[cfg(any(unix, test))] +pub(crate) fn events_require_host_mode_refresh(events: &[RawInputEvent]) -> bool { + events + .iter() + .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) +} + #[cfg(any(not(windows), test))] pub(crate) fn events_require_host_terminal_appearance_query(events: &[RawInputEvent]) -> bool { events @@ -633,7 +648,6 @@ enum ControlStringFamily { Osc, StTerminated, HostReplyCsi, - OrphanedSgrMouseTail, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -834,8 +848,10 @@ fn starts_with_incomplete_orphaned_sgr_mouse_tail(buffer: &[u8]) -> bool { } fn discard_complete_orphaned_sgr_mouse_tail(buffer: &mut Vec) -> bool { - let Some(terminator_len) = - control_string_terminator_for_family(buffer, ControlStringFamily::OrphanedSgrMouseTail) + let Some(terminator_len) = buffer + .iter() + .position(|byte| matches!(*byte, b'M' | b'm')) + .map(|idx| idx + 1) else { return false; }; @@ -855,19 +871,6 @@ fn discard_complete_orphaned_sgr_mouse_tail(buffer: &mut Vec) -> bool { true } -fn discard_or_buffer_orphaned_sgr_mouse_tail( - buffer: &mut Vec, - discard_until: &mut Option, - discarded_tail_bytes: &mut usize, -) { - if !discard_complete_orphaned_sgr_mouse_tail(buffer) { - *discarded_tail_bytes = buffer.len(); - *discard_until = (*discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES) - .then_some(ControlStringFamily::OrphanedSgrMouseTail); - buffer.clear(); - } -} - fn discard_host_reply_csi_tail(buffer: &mut Vec, discarded_tail_bytes: &mut usize) -> bool { let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes); let inspected = buffer.len().min(remaining); @@ -891,32 +894,76 @@ fn discard_host_reply_csi_tail(buffer: &mut Vec, discarded_tail_bytes: &mut *discarded_tail_bytes >= MAX_DISCARDED_CONTROL_TAIL_BYTES } -fn discard_orphaned_sgr_mouse_tail(buffer: &mut Vec, discarded_tail_bytes: &mut usize) -> bool { - let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes); - let inspected = buffer.len().min(remaining); +enum SgrMouseContinuation { + Incomplete, + Complete(usize), + Invalid, +} - for index in 0..inspected { - match buffer[index] { - b'0'..=b'9' | b';' => {} - b'M' | b'm' => { - buffer.drain(..=index); - return true; - } - _ => { - buffer.drain(..index); - return true; - } - } +fn classify_sgr_mouse_continuation(prefix: &[u8], tail: &[u8]) -> SgrMouseContinuation { + let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(prefix.len()); + let tail = &tail[..tail.len().min(remaining)]; + let final_index = tail + .iter() + .position(|byte| !byte.is_ascii_digit() && *byte != b';'); + let payload_len = final_index.unwrap_or(tail.len()); + let mut report = prefix.to_vec(); + report.extend_from_slice(&tail[..payload_len]); + if !plausible_sgr_mouse_prefix(&report) { + return SgrMouseContinuation::Invalid; + } + if let Some(index) = final_index { + report.push(tail[index]); + let valid = std::str::from_utf8(&report) + .ok() + .and_then(parse_sgr_mouse) + .is_some(); + return if valid { + SgrMouseContinuation::Complete(index + 1) + } else { + SgrMouseContinuation::Invalid + }; } - - *discarded_tail_bytes = discarded_tail_bytes.saturating_add(inspected); - if buffer.len() > inspected { - buffer.clear(); - return true; + if report.len() >= MAX_DISCARDED_CONTROL_TAIL_BYTES { + SgrMouseContinuation::Invalid + } else { + SgrMouseContinuation::Incomplete } +} - buffer.clear(); - false +// Reject impossible continuations early, without changing the general mouse +// parser. A partial last field (including zero) can still become valid. +fn plausible_sgr_mouse_prefix(report: &[u8]) -> bool { + let Some(body) = report.strip_prefix(b"\x1b[<") else { + return false; + }; + let mut fields = body.split(|byte| *byte == b';').enumerate().peekable(); + while let Some((field, digits)) = fields.next() { + if field > 2 { + return false; + } + if digits.is_empty() { + return fields.peek().is_none(); + } + if !digits.iter().all(u8::is_ascii_digit) { + return false; + } + let Some(value) = std::str::from_utf8(digits) + .ok() + .and_then(|digits| digits.parse::().ok()) + else { + return false; + }; + if field == 0 && value > u16::from(u8::MAX) { + return false; + } + if fields.peek().is_some() + && ((field == 0 && parse_mouse_cb(value as u8).is_none()) || (field == 1 && value == 0)) + { + return false; + } + } + true } fn osc_string_terminator(buffer: &[u8]) -> Option { @@ -946,10 +993,6 @@ fn control_string_terminator_for_family( ControlStringFamily::Osc => osc_string_terminator(buffer), ControlStringFamily::StTerminated => st_string_terminator(buffer), ControlStringFamily::HostReplyCsi => None, - ControlStringFamily::OrphanedSgrMouseTail => buffer - .iter() - .position(|byte| matches!(*byte, b'M' | b'm')) - .map(|idx| idx + 1), } } @@ -1319,6 +1362,16 @@ mod tests { assert!(!events_require_host_surface_redraw(&events, true)); } + #[test] + fn outer_focus_gained_requests_host_mode_refresh() { + assert!(events_require_host_mode_refresh( + &parse_raw_input_bytes_sync(b"\x1b[I") + )); + assert!(!events_require_host_mode_refresh( + &parse_raw_input_bytes_sync(b"\x1b[O") + )); + } + #[test] fn outer_focus_gained_requests_host_appearance_query() { let gained = parse_raw_input_bytes_sync(b"\x1b[I"); @@ -1499,6 +1552,14 @@ mod tests { (b"\x1b[57423;1u", KeyCode::Home, KeyModifiers::empty()), (b"\x1bOq", KeyCode::Char('1'), KeyModifiers::empty()), (b"\x1b[14~", KeyCode::F(4), KeyModifiers::empty()), + (b"\x1b[11;2~", KeyCode::F(1), KeyModifiers::SHIFT), + (b"\x1b[13;1:1~", KeyCode::F(3), KeyModifiers::empty()), + (b"\x1b[14;3~", KeyCode::F(4), KeyModifiers::ALT), + (b"\x1b[57364;1u", KeyCode::F(1), KeyModifiers::empty()), + (b"\x1b[57366;1u", KeyCode::F(3), KeyModifiers::empty()), + (b"\x1b[57366;2u", KeyCode::F(3), KeyModifiers::SHIFT), + (b"\x1b[57375;1u", KeyCode::F(12), KeyModifiers::empty()), + (b"\x1b[57376;1u", KeyCode::F(13), KeyModifiers::empty()), (b"\x1b[49:33;2:1u", KeyCode::Char('1'), KeyModifiers::SHIFT), ]; @@ -1533,11 +1594,11 @@ mod tests { } #[test] - fn modified_rxvt_f_key_alias_stays_unsupported() { + fn parses_modified_rxvt_f_key_alias() { let (event, consumed) = extract_one_event(b"\x1b[14;3~").unwrap(); assert_eq!(consumed, 7); - assert!(matches!(event, RawInputEvent::Unsupported)); + assert_raw_key(event, KeyCode::F(4), KeyModifiers::ALT); } #[test] @@ -1839,7 +1900,20 @@ mod tests { } #[test] - fn timed_out_sgr_mouse_discard_state_clears_at_quiescence() { + fn captured_sgr_mouse_tail_after_second_idle_flush_is_discarded() { + let mut framer = RawInputByteFramer::for_host_input(); + + // Issue #3911, 2026-09-13 07:02:14 UTC: this prefix timed out, + // then its tail arrived 33 ms later. The Unix reader flushes again + // after 10 ms of continued idle following the first discard. + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!(framer.push(b"5;28;31M"), Vec::>::new()); + } + + #[test] + fn timed_out_sgr_mouse_invalid_completion_is_preserved_after_idle() { let mut framer = RawInputByteFramer::default(); assert!(framer.push(b"\x1b[<3").is_empty()); @@ -1848,6 +1922,100 @@ mod tests { assert_eq!(framer.push(b"M"), vec![b"M".to_vec()]); } + #[test] + fn timed_out_sgr_mouse_completion_survives_read_splits_and_idle() { + let tail = b"5;28;31M"; + for split in 0..=tail.len() { + let mut framer = RawInputByteFramer::for_host_input(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(&tail[..split]).is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + let mut rest = tail[split..].to_vec(); + rest.extend_from_slice(b"x\x1b[A"); + assert_eq!(framer.push(&rest), vec![b"x".to_vec(), b"\x1b[A".to_vec()]); + assert!(framer.timed_out_mouse_prefix.is_none()); + assert!(!framer.has_pending_input()); + } + } + + #[test] + fn timed_out_sgr_mouse_invalid_syntax_releases_continuation() { + for tail in [ + b"5;0;31M".as_slice(), // zero coordinate + b"5;;31M", // empty field + b"5;28;31;1M", // extra field (the general parser is permissive) + b"999;28;31M", // button overflow + b"5;65536;31M", // coordinate overflow + ] { + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!(framer.push(tail).concat(), tail); + assert!(framer.timed_out_mouse_prefix.is_none()); + } + } + + #[test] + fn timed_out_sgr_mouse_interruption_preserves_text_and_new_events() { + for suffix in [ + b"x".as_slice(), + b"\x1b[A", // new key sequence + b"\x1b[200~paste\x1b[201~", // bracketed paste + "\u{4f60}".as_bytes(), // UTF-8 split across reads + ] { + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"5;28;").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let mut chunks = Vec::new(); + for byte in suffix { + chunks.extend(framer.push(&[*byte])); + } + let mut expected = b"5;28;".to_vec(); + expected.extend_from_slice(suffix); + assert_eq!(chunks.concat(), expected); + assert!(framer.timed_out_mouse_prefix.is_none()); + assert_eq!(framer.push(b"123M").concat(), b"123M"); + } + } + + #[test] + fn timed_out_sgr_mouse_budget_includes_prefix_and_preserves_overflow() { + let prefix = b"\x1b[<35;1;"; + let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES - prefix.len(); + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + let mut valid_tail = vec![b'0'; remaining - 2]; + valid_tail.extend_from_slice(b"1M"); + assert!(framer.push(&valid_tail).is_empty()); // complete exactly at limit + assert!(framer.timed_out_mouse_prefix.is_none()); + + for length in [remaining, remaining + 1024] { + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + let tail = vec![b'0'; length]; + assert_eq!(framer.push(&tail).concat(), tail); + assert!(framer.timed_out_mouse_prefix.is_none()); + assert_eq!(framer.push(b"1Mtext").concat(), b"1Mtext"); + } + + let mut framer = RawInputByteFramer::default(); + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + let mut tail = vec![b'0'; remaining - 1]; + assert!(framer.push(&tail).is_empty()); + assert!(framer.flush_timeout().is_empty()); + tail.extend_from_slice(b"01M"); + assert_eq!(framer.push(b"01M").concat(), tail); + assert!(framer.timed_out_mouse_prefix.is_none()); + } + #[test] fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() { let mut framer = RawInputFramer::default(); @@ -1905,6 +2073,7 @@ mod tests { assert!(framer.push(b"[<65;4").is_empty()); assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); let events = framer.push(b"3;26Mx"); assert_eq!(events.len(), 1); assert_raw_key( diff --git a/src/remote.rs b/src/remote.rs index c1c0f65f8a..a79ac77476 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -23,7 +23,7 @@ pub(crate) fn run_remote_api_bridge(args: &[String]) -> std::io::Result<()> { ), ) })?; - crate::platform::forward_remote_bridge_stdio(stream) + crate::platform::forward_remote_bridge_stdio(stream, false) } [flag] if flag == "--check" => { println!("herdr-api-bridge-v1"); diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 9a2bed5e81..2d08cb4353 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -40,6 +40,9 @@ const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://raw.githubusercontent.com/geril07/herdr/master/distribution/fork-preview.json"; const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; const REMOTE_OUTPUT_READY_MARKER: &str = "herdr-remote-output-ready:1"; +const WINDOWS_REMOTE_PATH_MARKER: &str = "herdr-remote-path:1:"; +const WINDOWS_REMOTE_INSTALL_DIR_MARKER: &str = "herdr-remote-install-dir:1:"; +const WINDOWS_REMOTE_INSTALL_RESULT_MARKER: &str = "herdr-remote-install-result:1:"; const SSH_CONTROL_SOCKET_NAME: &str = "ctl"; pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { let session_name = crate::session::active_name() @@ -261,7 +264,16 @@ impl RemoteExecutable { } fn bridge_command(&self, session_name: &str) -> String { - let args = Self::session_args(session_name, &["remote-client-bridge"]); + self.bridge_command_with_idle_timeout(session_name, false) + } + + fn bridge_command_with_idle_timeout(&self, session_name: &str, idle_timeout: bool) -> String { + let command = if idle_timeout { + &["remote-client-bridge", "--idle-timeout-v1"][..] + } else { + &["remote-client-bridge"][..] + }; + let args = Self::session_args(session_name, command); match self { Self::PosixShellPath(_) => { posix_remote_output_command(&format!("exec {}", self.command(&args))) @@ -313,6 +325,7 @@ pub(super) struct RemoteHerdr { install_suffix: String, executable: RemoteExecutable, platform: RemotePlatform, + bridge_idle_timeout: bool, } impl RemoteHerdr { @@ -331,6 +344,7 @@ impl RemoteHerdr { install_suffix, executable, platform, + bridge_idle_timeout: false, } } @@ -338,6 +352,11 @@ impl RemoteHerdr { self.executable = RemoteExecutable::PosixShellPath(shell_path); self } + + fn with_windows_path(mut self, path: String) -> Self { + self.executable = RemoteExecutable::WindowsPath(path); + self + } } fn windows_powershell_application_script(path: &str, args: &[&str]) -> String { @@ -592,6 +611,12 @@ impl RemoteSsh { command } + fn scp_command(&self) -> Command { + let mut command = Command::new("scp"); + apply_managed_scp_options(&mut command, self.options()); + command + } + fn sh_output(&self, script: &str) -> io::Result { let script = posix_remote_output_command(script); let mut child = self @@ -694,6 +719,86 @@ impl RemoteSsh { ))) } } + + fn copy_windows_file(&self, source_path: &Path, remote_path: &str) -> io::Result<()> { + let status = self + .scp_command() + .arg(source_path) + .arg(windows_scp_target(&self.target, remote_path)) + .status() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start scp: {err}")))?; + if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!("scp exited with {status}"))) + } + } + + fn install_windows_herdr( + &self, + remote_herdr: &RemoteHerdr, + source_path: &Path, + identity: &str, + sha256: &str, + ) -> io::Result { + let installer_dir = private_download_dir("windows-installer")?; + let local_installer_path = installer_dir.join("install.ps1"); + if let Err(err) = fs::write(&local_installer_path, crate::update::WINDOWS_INSTALLER) { + let _ = fs::remove_dir_all(&installer_dir); + return Err(err); + } + + let result = (|| { + let output = self.framed_user_shell_output(&windows_remote_install_prepare_command())?; + if !output.status.success() { + return Err(command_failed("remote install preparation failed", &output)); + } + let remote_dir = parse_windows_remote_path( + &String::from_utf8_lossy(&output.stdout), + WINDOWS_REMOTE_INSTALL_DIR_MARKER, + )?; + let install_result = (|| { + self.copy_windows_file( + &local_installer_path, + &format!(r"{remote_dir}\install.ps1"), + )?; + self.copy_windows_file( + source_path, + &format!(r"{remote_dir}\herdr-windows-x86_64.zip"), + )?; + + let output = self.framed_user_shell_output(&windows_remote_install_command( + &remote_dir, + identity, + sha256, + ))?; + if !output.status.success() { + return Err(command_failed("remote Windows install failed", &output)); + } + parse_windows_remote_path( + &String::from_utf8_lossy(&output.stdout), + WINDOWS_REMOTE_INSTALL_RESULT_MARKER, + ) + .map(|path| remote_herdr.clone().with_windows_path(path)) + })(); + + let cleanup = + self.framed_user_shell_output(&windows_remote_install_cleanup_command(&remote_dir)); + match install_result { + Err(err) => Err(err), + Ok(installed) => { + let output = cleanup?; + if output.status.success() { + Ok(installed) + } else { + Err(command_failed("remote install cleanup failed", &output)) + } + } + } + })(); + let _ = fs::remove_dir_all(installer_dir); + result + } } // Only interactive setup uses this relay. Background probes retain their @@ -810,6 +915,72 @@ fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String { ) } +fn windows_remote_install_prepare_command() -> String { + windows_powershell_script_command(&format!( + "$remoteInstallDir = Join-Path ([System.IO.Path]::GetTempPath()) ('herdr-remote-' + [System.Guid]::NewGuid().ToString('N')); [System.IO.Directory]::CreateDirectory($remoteInstallDir) | Out-Null; $encodedInstallDir = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($remoteInstallDir)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_INSTALL_DIR_MARKER}' + $encodedInstallDir); exit 0" + )) +} + +fn windows_scp_target(target: &str, remote_path: &str) -> String { + let remote_path = remote_path.replace('\\', "/"); + match target.strip_prefix("ssh://") { + Some(authority) => { + let mut encoded = String::new(); + for byte in remote_path.bytes() { + if byte.is_ascii_alphanumeric() || b"-_.~/:".contains(&byte) { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + format!("scp://{authority}/{encoded}") + } + None => format!("{target}:{remote_path}"), + } +} + +fn windows_remote_install_command(remote_dir: &str, identity: &str, sha256: &str) -> String { + let installer = crate::platform::quote_powershell_arg(&format!(r"{remote_dir}\install.ps1")); + let package = + crate::platform::quote_powershell_arg(&format!(r"{remote_dir}\herdr-windows-x86_64.zip")); + windows_powershell_script_command(&format!( + r#"$herdrInstaller = {installer}; $herdrPackage = {package}; & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $herdrInstaller -Channel {channel} -LocalPackagePath $herdrPackage -LocalPackageFormat zip -LocalPackageIdentity {identity} -LocalPackageSha256 {sha256}; if ($LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Join-Path $herdrHome 'packages\standalone\current'; $activeTarget = [string](Get-Item -LiteralPath $activeJunction -Force -ErrorAction Stop).Target; if ([string]::IsNullOrWhiteSpace($activeTarget)) {{ throw 'Herdr installer did not activate a concrete release.' }}; $installedHerdr = Join-Path $activeTarget 'herdr.exe'; if (-not (Test-Path -LiteralPath $installedHerdr -PathType Leaf)) {{ throw 'Herdr installer result does not contain herdr.exe.' }}; $encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.IO.Path]::GetFullPath($installedHerdr))); [Console]::Out.WriteLine('{WINDOWS_REMOTE_INSTALL_RESULT_MARKER}' + $encodedResult); exit 0"#, + channel = crate::platform::quote_powershell_arg(current_channel()), + identity = crate::platform::quote_powershell_arg(identity), + sha256 = crate::platform::quote_powershell_arg(sha256), + )) +} + +fn windows_remote_install_cleanup_command(remote_dir: &str) -> String { + windows_powershell_script_command(&format!( + "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction Stop; exit 0", + crate::platform::quote_powershell_arg(remote_dir) + )) +} + +fn parse_windows_remote_path(stdout: &str, marker: &str) -> io::Result { + let encoded = stdout + .lines() + .rev() + .find_map(|line| line.trim().strip_prefix(marker)) + .ok_or_else(|| io::Error::other(format!("remote command did not return {marker}")))?; + decode_windows_remote_path(encoded) +} + +fn decode_windows_remote_path(encoded: &str) -> io::Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + io::Error::other(format!("remote path result is not valid base64: {err}")) + })?; + let path = String::from_utf8(bytes) + .map_err(|err| io::Error::other(format!("remote path result is not valid UTF-8: {err}")))?; + if path.is_empty() { + return Err(io::Error::other("remote path result is empty")); + } + Ok(path) +} + impl Drop for RemoteSsh { fn drop(&mut self) { let Some(_options) = self @@ -870,6 +1041,26 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO } } +fn apply_managed_scp_options(command: &mut Command, options: Option<&ManagedSshOptions>) { + let Some(options) = options else { + return; + }; + + command.arg("-F").arg(&options.config_path); + if let Some(control_path) = &options.control_path { + command + .arg("-o") + .arg(format!( + "ControlPath={}", + ssh_config_quote(&control_path.to_string_lossy()) + )) + .arg("-o") + .arg("ControlMaster=auto") + .arg("-o") + .arg("ControlPersist=yes"); + } +} + impl InstallSource { fn persistent(path: PathBuf) -> Self { Self { @@ -900,7 +1091,12 @@ pub(super) fn prepare_remote_herdr( let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); if remote_herdr.platform.is_windows() { - return prepare_windows_remote_herdr(ssh, remote_herdr, require_surface_interest); + return prepare_windows_remote_herdr( + ssh, + remote_herdr, + live_handoff_enabled, + require_surface_interest, + ); } let override_binary = remote_binary_override_path()?; let remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?; @@ -970,14 +1166,13 @@ pub(super) fn prepare_remote_herdr( pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); - if remote_herdr.platform.is_windows() { - return prepare_windows_remote_herdr(ssh, remote_herdr, true) - .map(|prepared| prepared.remote_herdr); - } let candidates = remote_binary_candidates(ssh, &remote_herdr)?; - for candidate in candidates { - if remote_binary_supports_endpoint_requirement(ssh, &candidate, true)? { - return Ok(candidate); + for mut candidate in candidates { + if let Some(status) = remote_client_status(ssh, &candidate)? { + if status.supports_endpoint_requirement(&candidate.platform, true) { + candidate.bridge_idle_timeout = status.remote_bridge_idle_timeout; + return Ok(candidate); + } } } Err(io::Error::new( @@ -993,27 +1188,69 @@ pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result io::Result { - if !remote_binary_exists(ssh, &remote_herdr)? { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!( - "herdr.exe is not installed or is not on PATH on Windows host {}; install a compatible Windows package with remote host support and retry", - ssh.target() - ), - )); + let override_package = remote_binary_override_path()?; + let custom_package = override_package.is_some(); + let candidates = remote_binary_candidates(ssh, &remote_herdr)?; + if !custom_package { + for candidate in &candidates { + if remote_binary_supports_endpoint_requirement( + ssh, + candidate, + require_surface_interest, + )? { + return Ok(PreparedRemoteHerdr { + remote_herdr: candidate.clone(), + stop_after_install_approved: false, + }); + } + } + } + + let stop_after_install_approved = if let Some(candidate) = candidates.first() { + confirm_remote_install_with_running_server( + ssh, + candidate, + live_handoff_enabled, + require_surface_interest, + )? + } else { + false + }; + if !stop_after_install_approved { + confirm_remote_install( + &ssh.destination(), + &remote_herdr, + &install_source_description(&remote_herdr.platform, override_package.as_deref()), + )?; } + // Windows needs the complete package, including its app-local ConPTY runtime. + let source = match override_package { + Some(path) => InstallSource::persistent(path), + None => download_release_asset(&remote_herdr.platform)?, + }; + let install_result = (|| { + let sha256 = crate::checksum::file_sha256(&source.path)?; + ssh.install_windows_herdr( + &remote_herdr, + &source.path, + &windows_package_identity(custom_package, &sha256), + &sha256, + ) + })(); + source.cleanup(); + let remote_herdr = install_result?; if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? { return Err(io::Error::other(format!( - "herdr.exe on Windows host {} does not support saved SSH endpoint federation; install a compatible Windows package with remote host support and retry", - ssh.target() + "installed remote herdr at {}, but it does not support the required remote hosting capabilities", + remote_herdr.executable.display() ))); } - Ok(PreparedRemoteHerdr { remote_herdr, - stop_after_install_approved: false, + stop_after_install_approved, }) } @@ -1023,11 +1260,7 @@ pub(super) fn find_installed_remote_api_herdr( ) -> io::Result { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); - let candidates = if remote_herdr.platform.is_windows() { - vec![remote_herdr] - } else { - remote_binary_candidates(ssh, &remote_herdr)? - }; + let candidates = remote_binary_candidates(ssh, &remote_herdr)?; for candidate in candidates { let probe = ssh.framed_user_shell_output(&remote_api_bridge_command(&candidate, session, true))?; @@ -1126,6 +1359,17 @@ fn remote_binary_candidates( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, ) -> io::Result> { + if remote_herdr.platform.is_windows() { + let output = ssh.framed_user_shell_output(&windows_remote_binary_candidate_command())?; + if !output.status.success() { + return Err(command_failed("remote binary discovery failed", &output)); + } + return Ok(windows_remote_binary_candidates( + remote_herdr, + &String::from_utf8_lossy(&output.stdout), + )); + } + let mut candidates = Vec::new(); if let Some(path_candidate) = remote_binary_on_path_any(ssh, remote_herdr)? { @@ -1146,6 +1390,27 @@ fn remote_binary_candidates( Ok(candidates) } +fn windows_remote_binary_candidate_command() -> String { + windows_powershell_script_command(&format!( + r#"function Emit-HerdrPath([string]$CandidatePath) {{ if ([string]::IsNullOrWhiteSpace($CandidatePath) -or -not (Test-Path -LiteralPath $CandidatePath -PathType Leaf)) {{ return }}; $candidateFullPath = [System.IO.Path]::GetFullPath($CandidatePath); $encodedCandidate = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($candidateFullPath)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_PATH_MARKER}' + $encodedCandidate) }}; $pathCommand = Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $pathCommand) {{ Emit-HerdrPath $pathCommand.Source }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Get-Item -LiteralPath (Join-Path $herdrHome 'packages\standalone\current') -Force -ErrorAction SilentlyContinue; if ($null -ne $activeJunction -and -not [string]::IsNullOrWhiteSpace([string]$activeJunction.Target)) {{ Emit-HerdrPath (Join-Path ([string]$activeJunction.Target) 'herdr.exe') }}; exit 0"# + )) +} + +fn windows_remote_binary_candidates(remote_herdr: &RemoteHerdr, stdout: &str) -> Vec { + let mut candidates = Vec::new(); + for path in stdout.lines().filter_map(|line| { + line.trim() + .strip_prefix(WINDOWS_REMOTE_PATH_MARKER) + .and_then(|encoded| decode_windows_remote_path(encoded).ok()) + }) { + push_if_new_remote_binary_candidate( + &mut candidates, + remote_herdr.clone().with_windows_path(path), + ); + } + candidates +} + fn push_if_new_remote_binary_candidate(candidates: &mut Vec, candidate: RemoteHerdr) { if !candidates .iter() @@ -1281,8 +1546,11 @@ fn remote_binary_supports_endpoint_requirement( remote_herdr: &RemoteHerdr, require_surface_interest: bool, ) -> io::Result { - Ok(remote_client_status(ssh, remote_herdr)? - .is_some_and(|status| status.supports_endpoint_requirement(require_surface_interest))) + Ok( + remote_client_status(ssh, remote_herdr)?.is_some_and(|status| { + status.supports_endpoint_requirement(&remote_herdr.platform, require_surface_interest) + }), + ) } fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result { @@ -1375,7 +1643,7 @@ fn resolve_install_source( } fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { - if *platform != RemotePlatform::local() { + if platform.is_windows() || *platform != RemotePlatform::local() { return false; } @@ -1384,6 +1652,14 @@ fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { .unwrap_or(false) } +fn windows_package_identity(custom: bool, sha256: &str) -> String { + if custom { + format!("{}-custom.{sha256}", current_version()) + } else { + current_version() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum RemoteServerStatus { Running { @@ -1649,12 +1925,21 @@ struct RemoteClientStatusJson { endpoint_protocol_generation: Option, #[serde(default)] endpoint_capabilities: Vec, + #[serde(default)] + remote_host_bridge: bool, + #[serde(default)] + remote_bridge_idle_timeout: bool, } impl RemoteClientStatusJson { - fn supports_endpoint_requirement(&self, require_surface_interest: bool) -> bool { + fn supports_endpoint_requirement( + &self, + platform: &RemotePlatform, + require_surface_interest: bool, + ) -> bool { self.endpoint_protocol_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + && (!platform.is_windows() || self.remote_host_bridge) && (!require_surface_interest || [ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY, @@ -2175,7 +2460,13 @@ impl SshStdioBridge { ) -> io::Result { Self::start_command( target, - remote_herdr.executable.bridge_command(&session_name), + if noninteractive && remote_herdr.bridge_idle_timeout { + remote_herdr + .executable + .bridge_command_with_idle_timeout(&session_name, true) + } else { + remote_herdr.executable.bridge_command(&session_name) + }, local_socket, ssh_options, noninteractive, @@ -2304,6 +2595,27 @@ fn ssh_config_include_path(path: &Path) -> String { } } +/// Returns the `Include` value for the user's SSH config, or `None` when there +/// is nothing useful to include. +/// +/// Git for Windows' OpenSSH (MSYS) does not resolve Windows drive-letter paths +/// inside `Include`, so an absolute `C:/.../.ssh/config` path is silently +/// ignored when herdr runs under Git Bash and host aliases stop resolving. +/// `~/.ssh/config` is expanded by both Windows OpenSSH (to the user profile) +/// and MSYS OpenSSH (through `HOME`), so each shell's `ssh` reads the same user +/// config it would read by default. A missing config is harmless because +/// OpenSSH ignores an `Include` that matches nothing. +#[cfg(windows)] +fn ssh_user_config_include(_path: Option<&Path>) -> Option { + Some(ssh_config_quote("~/.ssh/config")) +} + +#[cfg(not(windows))] +fn ssh_user_config_include(path: Option<&Path>) -> Option { + path.filter(|path| path.is_file()) + .map(ssh_config_include_path) +} + /// Builds a temporary ssh config that includes the user's settings first, so /// OpenSSH's first-value-wins behavior preserves explicit user keepalives. fn write_managed_ssh_config() -> io::Result { @@ -2315,11 +2627,8 @@ fn write_managed_ssh_config() -> io::Result { .then(|| dir.join(SSH_CONTROL_SOCKET_NAME)); let mut contents = String::new(); - if let Some(user_config) = paths.user_config.filter(|path| path.is_file()) { - contents.push_str(&format!( - "Include {}\n", - ssh_config_include_path(&user_config) - )); + if let Some(include) = ssh_user_config_include(paths.user_config.as_deref()) { + contents.push_str(&format!("Include {include}\n")); } if let Some(system_config) = paths.system_config.filter(|path| path.is_file()) { contents.push_str(&format!( @@ -2822,6 +3131,20 @@ mod tests { } } + fn decode_windows_command(command: &str) -> String { + let encoded = command + .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") + .expect("encoded PowerShell command"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("base64"); + let utf16 = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + String::from_utf16(&utf16).expect("UTF-16LE") + } + #[cfg(unix)] thread_local! { pub(super) static UPLOAD_READ_ATTEMPTS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; @@ -3196,7 +3519,8 @@ mod tests { #[cfg(unix)] #[test] fn remote_ssh_command_uses_managed_config_when_present() { - let managed_config = write_managed_ssh_config().expect("write managed config"); + let mut managed_config = write_managed_ssh_config().expect("write managed config"); + managed_config.options.control_path = Some(PathBuf::from("/tmp/herdr test/control")); let config_path = managed_config.options.config_path.clone(); let control_path = managed_config .options @@ -3231,6 +3555,25 @@ mod tests { "example".to_string(), ] ); + + let scp_args = ssh + .scp_command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + scp_args, + vec![ + "-F".to_string(), + config_path.to_string_lossy().into_owned(), + "-o".to_string(), + format!("ControlPath=\"{}\"", control_path.to_string_lossy()), + "-o".to_string(), + "ControlMaster=auto".to_string(), + "-o".to_string(), + "ControlPersist=yes".to_string(), + ] + ); } #[cfg(windows)] @@ -3242,6 +3585,16 @@ mod tests { let contents = std::fs::read_to_string(&config_path).expect("read managed config"); assert!(contents.contains("ServerAliveInterval 15")); assert!(contents.contains("ServerAliveCountMax 4")); + // Git Bash's MSYS OpenSSH ignores drive-letter `Include` paths, so the + // user config must be referenced through `~` for aliases to resolve. + let include_at = contents + .find("Include \"~/.ssh/config\"") + .expect("user config Included through home"); + let fallback_at = contents.find("Host *").expect("fallback present"); + assert!( + include_at < fallback_at, + "user config must be Included before herdr's fallback: {contents}" + ); let ssh = RemoteSsh { target: "example".to_string(), @@ -3263,6 +3616,15 @@ mod tests { "example".to_string(), ] ); + let scp_args = ssh + .scp_command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + scp_args, + vec!["-F".to_string(), config_path.to_string_lossy().into_owned(),] + ); } #[cfg(windows)] @@ -3274,6 +3636,21 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn windows_ssh_user_config_include_uses_home_shorthand() { + // MSYS/Git Bash OpenSSH does not resolve `C:/...` in `Include`, so the + // user config is referenced through `~` regardless of the profile path. + assert_eq!( + ssh_user_config_include(Some(Path::new(r"C:\Users\A B\.ssh\config"))), + Some(r#""~/.ssh/config""#.to_string()) + ); + assert_eq!( + ssh_user_config_include(None), + Some(r#""~/.ssh/config""#.to_string()) + ); + } + #[test] fn noninteractive_ssh_stderr_capture_is_bounded() { let stderr = vec![b'x'; NONINTERACTIVE_SSH_STDERR_LIMIT + 4096]; @@ -3326,6 +3703,10 @@ mod tests { #[test] fn saved_machine_compatibility_uses_capabilities_not_release_or_private_protocol() { + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; let mut status = RemoteClientStatusJson { version: Some("0.1.0".into()), protocol: Some(1), @@ -3337,16 +3718,18 @@ mod tests { crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(), ], + remote_host_bridge: false, + remote_bridge_idle_timeout: false, }; - assert!(status.supports_endpoint_requirement(true)); + assert!(status.supports_endpoint_requirement(&linux, true)); for index in 0..status.endpoint_capabilities.len() { let removed = status.endpoint_capabilities.remove(index); - assert!(!status.supports_endpoint_requirement(true)); - assert!(status.supports_endpoint_requirement(false)); + assert!(!status.supports_endpoint_requirement(&linux, true)); + assert!(status.supports_endpoint_requirement(&linux, false)); status.endpoint_capabilities.insert(index, removed); } status.endpoint_protocol_generation = None; - assert!(!status.supports_endpoint_requirement(true)); + assert!(!status.supports_endpoint_requirement(&linux, true)); } #[test] @@ -3394,6 +3777,7 @@ mod tests { .collect::>(); assert_eq!(args, vec!["-T".to_string(), "example".to_string()]); + assert!(ssh.scp_command().get_args().next().is_none()); } #[test] @@ -3715,20 +4099,6 @@ mod tests { #[test] fn windows_remote_commands_use_one_encoded_powershell_grammar() { - fn decode(command: &str) -> String { - let encoded = command - .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") - .expect("encoded PowerShell command"); - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .expect("base64"); - let utf16 = bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect::>(); - String::from_utf16(&utf16).expect("UTF-16LE") - } - let executable = RemoteExecutable::WindowsPath("herdr.exe".to_string()); let commands = [ ( @@ -3782,7 +4152,7 @@ mod tests { "[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); " ); for (label, command, expected) in commands { - let script = decode(&command); + let script = decode_windows_command(&command); let script = script .strip_prefix(&preamble) .unwrap_or_else(|| panic!("{label} lacks shared output marker: {script}")); @@ -3790,6 +4160,86 @@ mod tests { } } + #[test] + fn windows_install_commands_copy_zip_and_return_concrete_path() { + assert_eq!( + windows_scp_target("ssh://user@example:2222", r"C:\Temp\A+B%20 C\ü\install.ps1"), + "scp://user@example:2222/C:/Temp/A%2BB%2520%20C/%C3%BC/install.ps1" + ); + assert_eq!( + windows_scp_target("example", r"C:\Temp\A+B%20 C\install.ps1"), + "example:C:/Temp/A+B%20 C/install.ps1" + ); + let remote_dir = r"C:\Temp\Herdr O'Brien\测试"; + assert_eq!( + windows_scp_target( + "user@example", + &format!(r"{remote_dir}\herdr-windows-x86_64.zip") + ), + "user@example:C:/Temp/Herdr O'Brien/测试/herdr-windows-x86_64.zip" + ); + assert_eq!( + windows_scp_target("ssh://user@example:2222", r"C:\Temp\install.ps1"), + "scp://user@example:2222/C:/Temp/install.ps1" + ); + + let command = decode_windows_command(&windows_remote_install_command( + remote_dir, + "0.9.0-custom.digest", + &"a".repeat(64), + )); + assert!(command.contains("-LocalPackageFormat zip")); + assert!(command.contains("-LocalPackageIdentity 0.9.0-custom.digest")); + assert!(command.contains(".Target")); + assert!(command.contains(WINDOWS_REMOTE_INSTALL_RESULT_MARKER)); + + let installed = r"C:\Users\test\测试\.herdr\packages\standalone\releases\0.9.0\herdr.exe"; + let encoded = base64::engine::general_purpose::STANDARD.encode(installed); + assert_eq!( + parse_windows_remote_path( + &format!("installer chatter\n{WINDOWS_REMOTE_INSTALL_RESULT_MARKER}{encoded}\n"), + WINDOWS_REMOTE_INSTALL_RESULT_MARKER + ) + .unwrap(), + installed + ); + assert!(parse_windows_remote_path( + "installer chatter", + WINDOWS_REMOTE_INSTALL_RESULT_MARKER + ) + .is_err()); + } + + #[test] + fn windows_candidate_discovery_prefers_path_then_active_release() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "windows", + arch: "x86_64", + }); + let path = r"C:\stale\herdr.exe"; + let active = r"C:\Users\test\.herdr\packages\standalone\releases\current\herdr.exe"; + let stdout = format!( + "{WINDOWS_REMOTE_PATH_MARKER}{}\nnoise\n{WINDOWS_REMOTE_PATH_MARKER}{}\n{WINDOWS_REMOTE_PATH_MARKER}{}\n", + base64::engine::general_purpose::STANDARD.encode(path), + base64::engine::general_purpose::STANDARD.encode(active), + base64::engine::general_purpose::STANDARD.encode(active), + ); + let candidates = windows_remote_binary_candidates(&remote_herdr, &stdout); + assert_eq!(candidates.len(), 2); + assert_eq!( + candidates[0].executable, + RemoteExecutable::WindowsPath(path.to_string()) + ); + assert_eq!( + candidates[1].executable, + RemoteExecutable::WindowsPath(active.to_string()) + ); + + let command = decode_windows_command(&windows_remote_binary_candidate_command()); + assert!(command.contains("Get-Command herdr.exe")); + assert!(command.contains("$activeJunction.Target")); + } + #[test] fn remote_output_framing_discards_any_banner_and_preserves_binary() { let payload = [0, 1, 2, 0xff, b'\n']; @@ -3901,6 +4351,25 @@ mod tests { } } + #[test] + fn remote_bridge_idle_timeout_requires_explicit_support_and_opt_in() { + let legacy = parse_client_status_json(r#"{"endpoint_protocol_generation":1}"#).unwrap(); + assert!(!legacy.remote_bridge_idle_timeout); + let current = parse_client_status_json( + r#"{"endpoint_protocol_generation":1,"remote_bridge_idle_timeout":true}"#, + ) + .unwrap(); + assert!(current.remote_bridge_idle_timeout); + let remote = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + assert!(remote + .executable + .bridge_command_with_idle_timeout("agents", true) + .ends_with(" --session agents remote-client-bridge --idle-timeout-v1")); + } + #[test] fn remote_bridge_command_uses_installed_binary() { let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { @@ -4125,6 +4594,10 @@ mod tests { #[test] fn saved_machine_setup_handoffs_old_server_missing_presentation_fence() { + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; // Captured from Rohan after installing a new binary while the old daemon stayed alive. let installed = parse_client_status_json( r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#, @@ -4134,8 +4607,8 @@ mod tests { r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#, ) .unwrap(); - assert!(installed.supports_endpoint_requirement(true)); - assert!(!running_binary.supports_endpoint_requirement(true)); + assert!(installed.supports_endpoint_requirement(&linux, true)); + assert!(!running_binary.supports_endpoint_requirement(&linux, true)); for (live_capabilities, expected) in [ ( running_binary.endpoint_capabilities, @@ -4181,6 +4654,38 @@ mod tests { } } + #[test] + fn windows_reuse_requires_explicit_remote_host_bridge() { + let windows = RemotePlatform { + os: "windows", + arch: "x86_64", + }; + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; + let old = + parse_client_status_json(r#"{"version":"0.8.2","endpoint_protocol_generation":1}"#) + .unwrap(); + let current = parse_client_status_json( + r#"{"version":"0.9.0","endpoint_protocol_generation":1,"remote_host_bridge":true}"#, + ) + .unwrap(); + + assert!(!old.supports_endpoint_requirement(&windows, false)); + assert!(current.supports_endpoint_requirement(&windows, false)); + assert!(old.supports_endpoint_requirement(&linux, false)); + assert!(!local_binary_can_seed_remote(&windows)); + assert_eq!( + windows_package_identity(true, &"a".repeat(64)), + format!("{}-custom.{}", current_version(), "a".repeat(64)) + ); + assert_eq!( + windows_package_identity(false, "ignored"), + current_version() + ); + } + #[test] fn parse_remote_server_status_json_reads_running_server() { assert_eq!( diff --git a/src/remote/host.rs b/src/remote/host.rs index 1ff6d1e557..08f5d1221e 100644 --- a/src/remote/host.rs +++ b/src/remote/host.rs @@ -3,7 +3,22 @@ use std::io; use std::time::Duration; -pub(crate) fn run_remote_client_bridge() -> io::Result<()> { +pub(crate) fn run_remote_client_bridge(args: &[String]) -> io::Result<()> { + let idle_timeout = match args { + [] => false, + [option] + if option == "--idle-timeout-v1" + && crate::platform::REMOTE_BRIDGE_IDLE_TIMEOUT_SUPPORTED => + { + true + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unsupported remote client bridge option", + )) + } + }; ensure_remote_server_running()?; let socket_path = crate::server::socket_paths::client_socket_path(); @@ -17,7 +32,7 @@ pub(crate) fn run_remote_client_bridge() -> io::Result<()> { ) })?; - crate::platform::forward_remote_bridge_stdio(stream) + crate::platform::forward_remote_bridge_stdio(stream, idle_timeout) } fn ensure_remote_server_running() -> io::Result<()> { diff --git a/src/selection.rs b/src/selection.rs index 7290b8f0c7..5f81fdcd83 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -188,6 +188,55 @@ impl

Selection

{ self.ordered() } + pub(crate) fn visible_rects(&self, inner: Rect, metrics: Option) -> [Rect; 3] { + let mut rects = [Rect::default(); 3]; + if !self.is_visible() || inner.is_empty() { + return rects; + } + let ((start_row, start_col), (end_row, end_col)) = self.ordered(); + let top = viewport_top_row(metrics); + let bottom = top.saturating_add(u32::from(inner.height)); + let rect = |first: u32, end: u32, left: u16, right: u16| { + let first = first.max(top); + let end = end.min(bottom); + let left = left.min(inner.width); + let right = right.min(inner.width); + if first >= end || left >= right { + Rect::default() + } else { + Rect::new( + inner.x + left, + inner.y + (first - top) as u16, + right - left, + (end - first) as u16, + ) + } + }; + if start_row == end_row { + rects[0] = rect( + start_row, + start_row.saturating_add(1), + start_col, + end_col.saturating_add(1), + ); + } else { + rects[0] = rect( + start_row, + start_row.saturating_add(1), + start_col, + inner.width, + ); + rects[1] = rect(start_row.saturating_add(1), end_row, 0, inner.width); + rects[2] = rect( + end_row, + end_row.saturating_add(1), + 0, + end_col.saturating_add(1), + ); + } + rects + } + /// Check whether a pane-relative cell (row, col) is inside the selection. pub fn contains(&self, viewport_row: u16, col: u16, metrics: Option) -> bool { if !self.is_visible() { @@ -323,6 +372,39 @@ pub fn write_osc52_bytes(bytes: &[u8]) { mod tests { use super::*; + #[test] + fn visible_rects_match_selection_cells_when_scrolled_and_reversed() { + let inner = Rect::new(10, 5, 8, 6); + for (start, end) in [ + ((0, 2), (0, 5)), + ((1, 3), (4, 2)), + ((4, 2), (1, 3)), + ((0, 0), (20, 7)), + ((20, 0), (22, 7)), + ] { + let selection = Selection::absolute_range((), start, end); + for top in [0, 2, 5, 20] { + let metrics = Some(ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: top, + viewport_rows: 6, + }); + let rects = selection.visible_rects(inner, metrics); + for row in 0..inner.height { + for col in 0..inner.width { + assert_eq!( + rects + .iter() + .any(|rect| rect.contains((inner.x + col, inner.y + row).into())), + selection.contains(row, col, metrics), + "start={start:?} end={end:?} top={top} row={row} col={col}", + ); + } + } + } + } + } + fn make_sel(sr: u32, sc: u16, er: u32, ec: u16) -> Selection { let mut sel = Selection::anchor(PaneId::from_raw(0), sr as u16, sc, None); sel.anchor = (sr, sc); diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 0af1fad1a7..4494f0535e 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -26,6 +26,7 @@ const CLIENT_SHELL_METHODS: &[&str] = &[ "pane.focus_direction", "pane.input.set", "pane.link.activate", + "pane.link.resolve", "pane.rename", "pane.resize", "pane.scroll", @@ -285,11 +286,15 @@ mod tests { "/tests/fixtures/endpoint-method-shapes-v1.json" ))) .expect("endpoint method shape fixture"); - let actual = endpoint_method_shape_digests(); + let mut actual = endpoint_method_shape_digests(); + // Freeze the additive method separately without rewriting the published fixture. + assert_eq!( + actual.remove("pane.link.resolve").as_deref(), + Some("f5e4a3e01453ae7b188f127ce951c12c20e0bebcc17cc364eeb6d1a01fd5bf81") + ); assert_eq!( - actual, - expected, + actual, expected, "an existing endpoint method changed shape; add load-bearing behavior as a new advertised method or explicitly gate new fields" ); } @@ -354,6 +359,15 @@ mod tests { offset_from_bottom: None, }, ))); + assert!(supports_client_shell_method(&Method::PaneLinkResolve( + crate::api::schema::PaneLinkActivateParams { + pane_id: "w1:p1".into(), + viewport_row: 0, + col: 0, + content_revision: None, + offset_from_bottom: None, + }, + ))); assert!(!supports_client_shell_method(&Method::Ping( crate::api::schema::PingParams::default(), ))); diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 23f07fc352..698cff8925 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -41,6 +41,9 @@ const MIN_CLIENT_ROWS: u16 = 1; /// and cleanup overhead. const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4); +#[cfg(unix)] +const OBSERVER_WRITE_TIMEOUT: Duration = Duration::from_secs(30); + /// Maximum input payload size (bytes) for a single `ClientMessage::Input`. const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB const MAX_CLIENT_SHELL_DIMENSION: u16 = 4096; @@ -974,7 +977,11 @@ fn client_writer_loop( } fn write_framed_bytes(stream: &mut LocalStream, data: &[u8]) -> bool { - if let Err(err) = stream.write_all(data) { + #[cfg(unix)] + let result = crate::platform::write_client_stream(stream, data); + #[cfg(windows)] + let result = stream.write_all(data); + if let Err(err) = result { debug!(err = %err, "client write failed, closing writer"); return false; } @@ -1004,8 +1011,14 @@ fn client_read_loop_with_endpoint_controls( endpoint_control_writer: Option<&ClientControlWriter>, ) -> io::Result<()> { while !should_quit.load(Ordering::Acquire) { - let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) - { + #[cfg(unix)] + let message = protocol::read_message( + &mut crate::platform::ClientStreamReader(&mut stream), + MAX_GRAPHICS_FRAME_SIZE, + ); + #[cfg(windows)] + let message = protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE); + let msg: ClientMessage = match message { Ok(msg) => msg, Err(protocol::FramingError::UnexpectedEof) => { // Client disconnected. @@ -1061,6 +1074,20 @@ fn client_read_loop_with_endpoint_controls( } } ClientMessage::ObserveTerminal { target } => { + #[cfg(unix)] + { + // macOS Unix sockets can block even with per-send MSG_DONTWAIT. + // ClientStreamReader preserves blocking reads on the shared socket. + let configured = stream + .set_send_timeout(Some(OBSERVER_WRITE_TIMEOUT)) + .and_then(|()| stream.set_nonblocking(true)); + if let Err(err) = configured { + let _ = crate::platform::shutdown_client_stream(&stream); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + return Err(err); + } + } ServerEvent::ClientObserveTerminal { client_id, target } } ClientMessage::ControlTerminal { target, takeover } => { @@ -1698,6 +1725,109 @@ mod tests { )); } + #[cfg(unix)] + #[test] + fn observer_write_timeout_resets_when_sending_makes_progress() { + use std::io::Read as _; + + let (mut client, mut server, _path) = local_stream_pair("slow-observer"); + server + .set_send_timeout(Some(Duration::from_millis(100))) + .unwrap(); + server.set_nonblocking(true).unwrap(); + let worker = std::thread::spawn(move || { + assert!(write_framed_bytes(&mut server, &vec![b'x'; 1024 * 1024])); + }); + client + .set_recv_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut received = 0; + let mut buffer = [0; 16 * 1024]; + while received < 1024 * 1024 { + let count = client.read(&mut buffer).unwrap(); + assert_ne!(count, 0, "observer disconnected while making progress"); + received += count; + std::thread::sleep(Duration::from_millis(5)); + } + worker.join().unwrap(); + } + + #[cfg(unix)] + #[test] + fn stalled_observer_timeout_releases_writer_and_reader() { + let (mut client, server, _path) = local_stream_pair("stalled-observer"); + let writer_stream = server.try_clone().expect("clone writer stream"); + let (writer, queue) = test_queue_writer(); + let (events, event_rx) = mpsc::channel(8); + let reader_events = events.clone(); + let (reader_done_tx, reader_done) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + let result = client_read_loop( + server, + 14, + &reader_events, + &Arc::new(AtomicBool::new(false)), + ); + let _ = reader_done_tx.send(result); + }); + protocol::write_message( + &mut client, + &ClientMessage::ObserveTerminal { + target: "w1:p1".into(), + }, + ) + .expect("observe request"); + let mut event_rx = event_rx; + assert!(matches!( + event_rx.blocking_recv(), + Some(ServerEvent::ClientObserveTerminal { client_id: 14, .. }) + )); + let LocalStream::UdSocket(socket) = &writer_stream; + assert_eq!( + socket.inner().write_timeout().unwrap(), + Some(Duration::from_secs(30)) + ); + assert_eq!(socket.inner().read_timeout().unwrap(), None); + let mut resize = Vec::new(); + protocol::write_message( + &mut resize, + &ClientMessage::Resize { + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + }, + ) + .unwrap(); + client.write_all(&resize[..2]).unwrap(); + std::thread::sleep(Duration::from_millis(20)); + client.write_all(&resize[2..]).unwrap(); + assert!(matches!( + event_rx.blocking_recv(), + Some(ServerEvent::ClientResize { client_id: 14, .. }) + )); + writer_stream + .set_send_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let (writer_done_tx, writer_done) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + client_writer_loop(writer_stream, 14, queue, events); + let _ = writer_done_tx.send(()); + }); + writer.render.try_send(vec![0; 4 * 1024 * 1024]).unwrap(); + writer_done + .recv_timeout(Duration::from_millis(350)) + .expect("writer timed out"); + reader_done + .recv_timeout(Duration::from_secs(2)) + .expect("reader released") + .unwrap(); + worker.join().unwrap(); + reader.join().unwrap(); + assert!(writer.control.send(vec![1]).is_err()); + } + #[test] fn clamp_terminal_size_zero_zero() { assert_eq!( diff --git a/src/server/clients.rs b/src/server/clients.rs index 34d2d252e6..f50d7afe94 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -187,6 +187,8 @@ pub(crate) struct ClientConnection { pub(crate) shell_location: Option, /// Last coherent shell replacement sent to this client. pub(crate) shell_snapshot: Option, + /// View policy paired with the last coherent shell replacement. + pub(crate) shell_agent_view: Option, /// Monotonic shell replacement revision for this connection. pub(crate) shell_projection_revision: u64, /// Whether this shell is waiting for one ordered endpoint command response. @@ -255,6 +257,7 @@ impl ClientConnection { staged_clipboard_files: Vec::new(), shell_location: None, shell_snapshot: None, + shell_agent_view: None, shell_projection_revision: 0, shell_endpoint_command_in_flight: false, shell_endpoint_command_surface_revision: None, diff --git a/src/server/handoff.rs b/src/server/handoff.rs index 2cd17144e0..aaa87f382b 100644 --- a/src/server/handoff.rs +++ b/src/server/handoff.rs @@ -22,8 +22,11 @@ const HANDOFF_VERSION: u32 = 1; const READY_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(unix)] const OWNED_ACK_TIMEOUT: Duration = Duration::from_millis(500); +// Descriptors are transferred in batches of this size. A single SCM_RIGHTS +// control message caps out at 253 descriptors on Linux and 254 on macOS, so the +// batch stays well below both limits and the number of panes stays unbounded. #[cfg(unix)] -pub(crate) const MAX_FDS_PER_HANDOFF: usize = 64; +const FDS_PER_MESSAGE: usize = 64; #[cfg(unix)] pub(crate) const MAX_REPLAY_BYTES_PER_PANE: usize = 8 * 1024; #[cfg(unix)] @@ -174,12 +177,6 @@ pub(crate) fn accept_and_validate_on( #[cfg(unix)] pub(crate) fn send_fds_and_wait_restored(stream: &mut UnixStream, fds: &[RawFd]) -> io::Result<()> { - if fds.len() > MAX_FDS_PER_HANDOFF { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("handoff supports at most {MAX_FDS_PER_HANDOFF} pane file descriptors at once"), - )); - } send_fds(stream, fds)?; stream.set_read_timeout(Some(READY_TIMEOUT))?; @@ -382,6 +379,14 @@ fn read_line_unbuffered(stream: &mut UnixStream) -> io::Result { #[cfg(unix)] fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> { + for batch in fds.chunks(FDS_PER_MESSAGE) { + send_fd_batch(stream, batch)?; + } + Ok(()) +} + +#[cfg(unix)] +fn send_fd_batch(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> { if fds.is_empty() { return Ok(()); } @@ -414,17 +419,48 @@ fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> { Ok(()) } +#[cfg(unix)] +fn close_raw_fds(fds: &[RawFd]) { + for fd in fds { + let _ = unsafe { libc::close(*fd) }; + } +} + #[cfg(unix)] fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result> { - if expected == 0 { - return Ok(Vec::new()); + let mut out: Vec = Vec::with_capacity(expected); + while out.len() < expected { + let wanted = (expected - out.len()).min(FDS_PER_MESSAGE); + let batch = match recv_fd_batch(stream, wanted) { + Ok(batch) => batch, + Err(err) => { + close_raw_fds(&out); + return Err(err); + } + }; + if batch.is_empty() { + let received = out.len(); + close_raw_fds(&out); + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "handoff stream closed after {received} of {expected} pane file descriptors" + ), + )); + } + out.extend(batch); } + Ok(out) +} + +#[cfg(unix)] +fn recv_fd_batch(stream: &UnixStream, wanted: usize) -> io::Result> { let mut byte = [0u8; 1]; let mut iov = [libc::iovec { iov_base: byte.as_mut_ptr() as *mut libc::c_void, iov_len: byte.len(), }]; - let fd_bytes = expected * std::mem::size_of::(); + let fd_bytes = wanted * std::mem::size_of::(); let mut control = vec![0u8; unsafe { libc::CMSG_SPACE(fd_bytes as u32) as usize }]; let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; msg.msg_iov = iov.as_mut_ptr(); @@ -436,34 +472,51 @@ fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result> { if read < 0 { return Err(io::Error::last_os_error()); } - if msg.msg_flags & libc::MSG_CTRUNC != 0 { - return Err(io::Error::other("handoff fd control message was truncated")); - } let mut out = Vec::new(); unsafe { - let cmsg = libc::CMSG_FIRSTHDR(&msg); - if cmsg.is_null() - || (*cmsg).cmsg_level != libc::SOL_SOCKET - || (*cmsg).cmsg_type != libc::SCM_RIGHTS - { - return Err(io::Error::other("handoff fd message missing SCM_RIGHTS")); - } - let data_len = ((*cmsg).cmsg_len as usize).saturating_sub(libc::CMSG_LEN(0) as usize); - let count = data_len / std::mem::size_of::(); - let data = libc::CMSG_DATA(cmsg) as *const RawFd; - for idx in 0..count { - out.push(*data.add(idx)); + let control_end = control.as_ptr() as usize + msg.msg_controllen as usize; + let mut cmsg = libc::CMSG_FIRSTHDR(&msg); + while !cmsg.is_null() { + if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS { + let data = libc::CMSG_DATA(cmsg); + // Bound the payload by both the header's own length and the + // bytes the kernel wrote into `control`, so the read below can + // never run past the buffer. + let available = control_end.saturating_sub(data as usize); + let data_len = ((*cmsg).cmsg_len as usize) + .saturating_sub(libc::CMSG_LEN(0) as usize) + .min(available); + let count = data_len / std::mem::size_of::(); + let data = data as *const RawFd; + for idx in 0..count { + out.push(*data.add(idx)); + } + } + cmsg = libc::CMSG_NXTHDR(&msg, cmsg); } } - if out.len() != expected { - for fd in out { - let _ = unsafe { libc::close(fd) }; - } + + // Truncation means the kernel closed the descriptors that did not fit, so + // the batch is unrecoverable rather than merely short. + if msg.msg_flags & libc::MSG_CTRUNC != 0 { + close_raw_fds(&out); + return Err(io::Error::other("handoff fd control message was truncated")); + } + if read == 0 { + close_raw_fds(&out); + return Ok(Vec::new()); + } + if out.len() > wanted { + let received = out.len(); + close_raw_fds(&out); return Err(io::Error::other(format!( - "expected {expected} handoff fds, received fewer" + "handoff fd message carried {received} descriptors, expected at most {wanted}" ))); } + if out.is_empty() { + return Err(io::Error::other("handoff fd message missing SCM_RIGHTS")); + } Ok(out) } diff --git a/src/server/headless.rs b/src/server/headless.rs index cf0f07122c..06d5a7b7bd 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2006,6 +2006,21 @@ impl HeadlessServer { ); let location = crate::server::clients::ClientShellLocation::from_snapshot(&seed_snapshot); + let agent_view = self.app.state.agent_view_override.clone(); + let projection_message = match agent_view.as_ref() { + Some(view) => match crate::protocol::endpoint::agent_view_projection_message( + &seed_snapshot.boot_id, + seed_snapshot.revision, + Some(view), + ) { + Ok(message) => Some(message), + Err(err) => { + warn!(client_id, err = %err, "failed to encode endpoint agent view"); + return false; + } + }, + None => None, + }; let snapshot_message = match crate::protocol::endpoint::snapshot_message(&seed_snapshot) { Ok(message) => message, @@ -2016,10 +2031,14 @@ impl HeadlessServer { }; connection.shell_location = Some(location); connection.shell_snapshot = Some(seed_snapshot); + connection.shell_agent_view = agent_view; self.clients.insert(client_id, connection); if self.app.state.popup_pane.is_some() && self.popup_owner_tab_id.is_none() { self.popup_owner_tab_id = self.shell_tab_id_for_client(client_id); } + if let Some(message) = projection_message { + self.send_to_client(client_id, message); + } self.send_to_client(client_id, snapshot_message); if surface_active { self.foreground_client_id = Some(client_id); diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index 3ce7dc2773..a152f2e236 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -259,6 +259,7 @@ impl HeadlessServer { | Method::PaneFocusDirection(_) | Method::PaneInputSet(_) | Method::PaneLinkActivate(_) + | Method::PaneLinkResolve(_) | Method::PaneRename(_) | Method::PaneResize(_) | Method::PaneScroll(_) diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs index ab1448c3ab..f7ad87dd97 100644 --- a/src/server/headless/lifecycle.rs +++ b/src/server/headless/lifecycle.rs @@ -53,16 +53,6 @@ impl HeadlessServer { } } } - if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF { - let _ = std::fs::remove_file(&socket_path); - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "live handoff supports at most {} panes in one update; close panes or restart herdr normally", - crate::server::handoff::MAX_FDS_PER_HANDOFF - ), - )); - } self.handoff_in_progress = true; self.disconnect_all_clients_for_handoff(); diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index 5c4b686304..f9f067d7e6 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -46,10 +46,7 @@ impl HeadlessServer { let child_requests_mouse = focused.is_some_and(|(runtime, _)| runtime.mouse_reporting_enabled()); let sgr_pixels = client.pixel_mouse - && focused.is_some_and(|(runtime, pane_id)| { - self.app.pane_graphics.active_for_pane(pane_id) - && runtime.sgr_pixel_mouse_enabled() - }); + && focused.is_some_and(|(runtime, _)| runtime.sgr_pixel_mouse_enabled()); Some(( client_id, client.shell_surface_active @@ -462,6 +459,16 @@ impl HeadlessServer { let mut broken_clients: Vec = Vec::new(); for (client_id, (cols, rows), cell_size, _is_foreground, mode) in render_targets { + #[cfg(unix)] + if matches!(mode, ClientConnectionMode::TerminalObserve { .. }) + && self + .clients + .get(&client_id) + .is_some_and(|client| client.deferred_render() != DeferredRender::None) + { + // The writer-drained event schedules recovery, even if pane output stops. + continue; + } let area = Rect::new(0, 0, cols, rows); // Popup percentages resolve against the full outer terminal, not // the pane surface. Older clients that never reported a full size @@ -481,6 +488,7 @@ impl HeadlessServer { .clients .get(&client_id) .and_then(|client| client.shell_location.clone()); + let agent_view = self.app.state.agent_view_override.clone(); let Some(client) = self.clients.get_mut(&client_id) else { continue; }; @@ -497,19 +505,52 @@ impl HeadlessServer { self.server_config_diagnostic_without_keybindings.clone() }; candidate.revision = client.shell_projection_revision; - if client.shell_snapshot.as_ref() != Some(&candidate) { + if client.shell_snapshot.as_ref() != Some(&candidate) + || client.shell_agent_view != agent_view + { client.shell_projection_revision = client.shell_projection_revision.saturating_add(1); candidate.revision = client.shell_projection_revision; - let message = match crate::protocol::endpoint::snapshot_message(&candidate) { - Ok(message) => message, + let projection_message = if agent_view.is_some() + || client.shell_agent_view.is_some() + { + match crate::protocol::endpoint::agent_view_projection_message( + &candidate.boot_id, + candidate.revision, + agent_view.as_ref(), + ) { + Ok(message) => Some(message), + Err(err) => { + warn!(client_id, err = %err, "failed to encode endpoint agent view"); + broken_clients.push(client_id); + continue; + } + } + } else { + None + }; + let snapshot_message = + match crate::protocol::endpoint::snapshot_message(&candidate) { + Ok(message) => message, + Err(err) => { + warn!(client_id, err = %err, "failed to encode endpoint snapshot"); + broken_clients.push(client_id); + continue; + } + }; + let projection_framed = match projection_message + .as_ref() + .map(Self::frame_server_message) + .transpose() + { + Ok(framed) => framed, Err(err) => { - warn!(client_id, err = %err, "failed to encode endpoint snapshot"); + warn!(client_id, err = %err, "failed to frame endpoint agent view"); broken_clients.push(client_id); continue; } }; - let framed = match Self::frame_server_message(&message) { + let snapshot_framed = match Self::frame_server_message(&snapshot_message) { Ok(framed) => framed, Err(err) => { warn!(client_id, err = %err, "failed to frame endpoint snapshot"); @@ -521,11 +562,14 @@ impl HeadlessServer { broken_clients.push(client_id); continue; }; - if writer.control.send(framed).is_err() { + if projection_framed.is_some_and(|framed| writer.control.send(framed).is_err()) + || writer.control.send(snapshot_framed).is_err() + { broken_clients.push(client_id); continue; } client.shell_snapshot = Some(candidate); + client.shell_agent_view = agent_view; } shell_projection_revision = client.shell_projection_revision; if !client.shell_surface_active { diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index cc97c325b5..d66db0b1bd 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -13,6 +13,16 @@ fn client_shell_snapshot(message: ServerMessage) -> Box crate::protocol::endpoint::EndpointAgentViewProjection { + let ServerMessage::EndpointControl { kind, data } = message else { + panic!("expected client agent view projection"); + }; + assert_eq!(kind, crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND); + serde_json::from_str(&data).expect("decode client agent view projection") +} + fn test_headless_server() -> HeadlessServer { test_headless_server_with_event_hub(api::EventHub::default()) } @@ -736,6 +746,94 @@ fn terminal_client_endpoint_request_error_removes_client() { assert!(!server.clients.contains_key(&client_id)); } +#[tokio::test] +async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots() { + use crate::api::schema::{ + AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewSetParams, AgentViewValue, + }; + + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 77, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + surface_active: false, + writer, + }) + ); + let initial = client_shell_snapshot(read_server_message( + control_rx.recv().expect("initial snapshot"), + )); + + let mut view = AgentViewSetParams { + source: "example.views".into(), + label: Some("focus".into()), + filter: Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("working".into()), + }), + sort: Vec::new(), + }; + server.app.state.agent_view_override = Some(view.clone()); + server.render_and_stream(); + let set = client_agent_view_projection(read_server_message( + control_rx.recv().expect("set projection"), + )); + let set_snapshot = client_shell_snapshot(read_server_message( + control_rx.recv().expect("set snapshot"), + )); + assert!(set.revision > initial.revision); + assert_eq!(set.revision, set_snapshot.revision); + assert_eq!( + set.view.map(serde_json::from_value).transpose().unwrap(), + Some(view.clone()) + ); + + view.filter = Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("blocked".into()), + }); + server.app.state.agent_view_override = Some(view.clone()); + server.render_and_stream(); + let replacement = client_agent_view_projection(read_server_message( + control_rx.recv().expect("replacement projection"), + )); + let replacement_snapshot = client_shell_snapshot(read_server_message( + control_rx.recv().expect("replacement snapshot"), + )); + assert!(replacement.revision > set.revision); + assert_eq!(replacement.revision, replacement_snapshot.revision); + assert_eq!( + replacement + .view + .map(serde_json::from_value) + .transpose() + .unwrap(), + Some(view) + ); + + server.app.state.agent_view_override = None; + server.render_and_stream(); + let cleared = client_agent_view_projection(read_server_message( + control_rx.recv().expect("clear projection"), + )); + let cleared_snapshot = client_shell_snapshot(read_server_message( + control_rx.recv().expect("clear snapshot"), + )); + assert!(cleared.revision > replacement.revision); + assert_eq!(cleared.revision, cleared_snapshot.revision); + assert!(cleared.view.is_none()); + assert!(cleared_snapshot.agent_view_label.is_none()); +} + #[tokio::test] async fn client_shell_receives_metadata_then_shell_free_pane_surface() { let mut server = test_headless_server(); @@ -3574,6 +3672,53 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() { rt.shutdown_timeout(Duration::from_millis(100)); } +#[cfg(unix)] +#[test] +fn backpressured_observer_skips_runtime_access_and_recovers_without_new_output() { + with_terminal_session_test_server(|server, terminal_id, target, _| { + let (writer, control, frames) = test_client_writer(); + server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + }); + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target, + }); + server.render_and_stream(); + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"LATEST"), + ); + server.render_and_stream(); + assert_eq!(server.clients[&7].deferred_render(), DeferredRender::Full); + + // An absent runtime makes any attempted rendering observable without timing a lock. + let runtime = server.app.terminal_runtimes.remove(&terminal_id).unwrap(); + server.render_and_stream(); + server.app.terminal_runtimes.insert(terminal_id, runtime); + assert!( + server.clients.contains_key(&7), + "backpressure must skip runtime access" + ); + assert!(control.try_recv().is_err()); + + let _ = frames.recv().expect("previously accepted frame"); + assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 7 })); + server.render_and_stream(); + let ServerMessage::Terminal(frame) = read_server_message(frames.recv().unwrap()) else { + panic!("terminal update"); + }; + assert!(String::from_utf8_lossy(&frame.bytes).contains("LATEST")); + assert_eq!(server.clients[&7].deferred_render(), DeferredRender::None); + }); +} + #[test] fn terminal_observe_allows_multiple_clients_without_attach_ownership() { with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 0414b6b366..0b7a8095a5 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -594,28 +594,22 @@ fn direct_stream_message( } #[tokio::test] -async fn pixel_mouse_activation_requires_graphics_demand_not_direct_transport() { - let (mut server, _client_rx, pane_id) = +async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { + let (mut server, _client_rx, _pane_id) = retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); let (writer, control_rx, _render_rx) = test_client_writer(); let client = server.clients.get_mut(&1).unwrap(); client.writer = Some(writer); client.direct_graphics = false; client.pixel_mouse = true; + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; client.host_mouse_capture_active = None; client.host_sgr_pixels_active = None; server.app.direct_graphics_available = false; - server.stream_host_mouse_capture_mode(); - assert!(matches!( - read_server_message(control_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::MouseCapture { - enabled: true, - sgr_pixels: false - } - )); - - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); server.stream_host_mouse_capture_mode(); assert!(matches!( read_server_message(control_rx.recv_timeout(Duration::from_millis(100)).unwrap()), diff --git a/src/sound.rs b/src/sound.rs index d1b4fc90d9..b8870b8b38 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -167,8 +167,18 @@ if ($script:timedOut) { throw 'sound playback timed out' } "# } +/// Builds the same player script with a shorter timer for tests. Production +/// keeps the fixed 15 second bound in `windows_media_player_script`. +#[cfg(test)] +fn windows_media_player_script_with_timeout(timeout_seconds: u64) -> String { + windows_media_player_script().replace( + "FromSeconds(15)", + &format!("FromSeconds({timeout_seconds})"), + ) +} + #[cfg(any(windows, test))] -fn windows_player_command(path: &Path) -> Command { +fn windows_player_command(path: &Path, script: &str) -> Command { let mut command = crate::noninteractive_process::command("powershell.exe"); command .args([ @@ -178,7 +188,7 @@ fn windows_player_command(path: &Path) -> Command { "-ExecutionPolicy", "Bypass", "-Command", - windows_media_player_script(), + script, ]) .env(WINDOWS_SOUND_PATH_ENV, path); command @@ -186,7 +196,7 @@ fn windows_player_command(path: &Path) -> Command { #[cfg(windows)] fn run_windows_player(path: &Path) -> Result { - windows_player_command(path) + windows_player_command(path, windows_media_player_script()) .output() .map_err(|e| format!("Windows MediaPlayer playback failed: {e}")) } @@ -430,7 +440,7 @@ mod tests { fn windows_media_player_uses_process_environment_and_dispatcher() { let script = windows_media_player_script(); let path = Path::new(r"C:\sound dir\döne.mp3"); - let command = windows_player_command(path); + let command = windows_player_command(path, script); let env_path = command.get_envs().find_map(|(key, value)| { (key == std::ffi::OsStr::new(WINDOWS_SOUND_PATH_ENV)) .then_some(value) @@ -443,23 +453,30 @@ mod tests { assert!(script.contains("Dispatcher]::PushFrame")); assert!(script.contains("add_MediaEnded")); assert!(script.contains("add_MediaFailed")); + assert!(script.contains("FromSeconds(15)")); + assert!(windows_media_player_script_with_timeout(2).contains("FromSeconds(2)")); assert_eq!(env_path, Some(path.as_os_str())); assert!(!command.get_args().any(|arg| arg == path.as_os_str())); } #[cfg(windows)] #[test] - fn windows_media_player_reports_invalid_media_without_waiting_for_timeout() { + fn windows_media_player_reports_invalid_media() { let _lock = crate::integration::integration_env_lock(); let path = temp_sound_path(); std::fs::write(&path, b"not an mp3").unwrap(); - let output = run_windows_player(&path).unwrap(); + let script = windows_media_player_script_with_timeout(2); + // Whether the host media stack raises MediaFailed before the playback + // timeout is environment-dependent, so use a short test-only timer and + // accept either terminal error instead of the production 15 second wait. + let output = windows_player_command(&path, &script).output().unwrap(); let _ = std::fs::remove_file(path); assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - String::from_utf8_lossy(&output.stderr).contains("sound media failed"), - "stderr should identify a MediaFailed error" + stderr.contains("sound media failed") || stderr.contains("sound playback timed out"), + "stderr should report why playback stopped: {stderr}" ); } } diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 91b1d582e7..e82a5a3af9 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -861,7 +861,9 @@ mod tests { clear_title: false, clear_display_agent: false, clear_state_labels: false, - ttl: Some(Duration::from_millis(1)), + // Expiry is forced with the captured deadline below; keep the + // no-extension assertion independent of wall-clock scheduling. + ttl: Some(Duration::from_secs(60)), seq: None, }); let old_deadline = terminal.next_agent_metadata_expiry().unwrap(); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index a409545b9b..4679579189 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -423,6 +423,19 @@ impl TerminalRuntime { self.0.visible_hyperlinks(area) } + pub(crate) fn link_regions_at( + &self, + col: u16, + row: u16, + resolve: fn(&str, usize) -> Option>, + ) -> Vec { + self.0.link_regions_at(col, row, resolve) + } + + pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option { + self.0.link_target_at(col, row) + } + pub fn kitty_image_placements_with_data_filter( &self, needs_data: F, diff --git a/src/terminal/state.rs b/src/terminal/state.rs index a7c8457481..7ceaede7a4 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -574,7 +574,12 @@ impl TerminalState { self.hook_authority = None; self.persisted_agent_session = durable_session; } - if agent_released { + // Observing a process exit is not the same as the agent being gone: the + // observation can be wrong while the agent keeps running, and the name + // is the only handle its owner has on the pane. Detection uncertainty + // already keeps the name, so free it at the point the agent actually + // leaves the pane - a recorded exit with no agent detected any more. + if agent.is_none() && self.recent_agent_process_exit.is_some() { self.clear_agent_name(); } TerminalStateMutation { @@ -5548,6 +5553,94 @@ mod tests { assert!(terminal.hook_authority.is_none()); } + #[test] + fn a_process_exit_observation_alone_does_not_free_the_name() { + // The pane keeps reporting the same agent throughout: this models a + // process-exit observation that is wrong (the agent is still running), + // which is what `agent_alias_survives_detection_uncertainty_...` + // already refuses to let destroy the name through the detection path. + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_agent_name("reviewer".into()); + + // Contract today: losing detection does not cost the pane its name. + terminal.set_detected_state(None, AgentState::Unknown); + assert_eq!( + terminal.agent_name.as_deref(), + Some("reviewer"), + "detection uncertainty must not release the name" + ); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + + // A single process-exit observation for that same agent destroys it. + let exit = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + now, + ); + assert!(exit.agent_released); + assert_eq!( + terminal.agent_name.as_deref(), + Some("reviewer"), + "a process-exit observation for the still-detected agent must not \ + destroy a name that detection uncertainty is allowed to keep" + ); + + // And the agent proving it is alive again must not leave the pane + // permanently unreachable by the name its owner assigned. + terminal.set_detected_agent_process_at(Agent::Pi, now + Duration::from_secs(1)); + assert_eq!( + terminal.agent_name.as_deref(), + Some("reviewer"), + "the name must still resolve once the agent is observed alive again" + ); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + } + + #[test] + fn a_confirmed_agent_exit_still_frees_the_name_for_reuse() { + // The other side of `a_process_exit_observation_alone_does_not_free_the_name`: + // once the agent is actually gone from the pane the name must be + // released, so `agent start` can reuse it. + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_agent_name("reviewer".into()); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + now, + ); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + // The agent really is gone: the next observation finds no agent while + // the recorded exit still stands. + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_secs(1), + ); + assert_eq!( + terminal.agent_name, None, + "a confirmed exit must release the name" + ); + assert!(!terminal.is_agent_terminal()); + } + #[test] fn agent_alias_survives_detection_uncertainty_and_reported_release_but_not_replacement() { let mut terminal = test_terminal(); diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs index b38d251b8b..810fa8710a 100644 --- a/src/terminal_modes.rs +++ b/src/terminal_modes.rs @@ -1,37 +1,30 @@ use std::io::{self, Write}; -#[cfg(any(not(windows), test))] const DISABLE_HOST_MOUSE_REPORTING_SEQUENCE: &[u8] = - b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; + b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?9l"; #[cfg(any(windows, test))] -const WINDOWS_SSH_MOUSE_REPORTING_ENABLE_SEQUENCE: &[u8] = +const WINDOWS_MOUSE_REPORTING_ENABLE_SEQUENCE: &[u8] = b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"; #[cfg(any(windows, test))] -const WINDOWS_SSH_MOUSE_REPORTING_DISABLE_SEQUENCE: &[u8] = +const WINDOWS_MOUSE_REPORTING_DISABLE_SEQUENCE: &[u8] = b"\x1b[?1016l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; -#[cfg(not(windows))] pub(crate) fn clear_host_mouse_reporting(writer: &mut W) -> io::Result<()> { writer.write_all(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE)?; writer.flush() } -#[cfg(windows)] -pub(crate) fn clear_host_mouse_reporting(_writer: &mut W) -> io::Result<()> { - Ok(()) -} - #[cfg(any(windows, test))] -pub(crate) fn set_windows_ssh_mouse_reporting( +pub(crate) fn set_windows_mouse_reporting( writer: &mut W, enabled: bool, sgr_pixels: bool, ) -> io::Result<()> { writer.write_all(if enabled { - WINDOWS_SSH_MOUSE_REPORTING_ENABLE_SEQUENCE + WINDOWS_MOUSE_REPORTING_ENABLE_SEQUENCE } else { - WINDOWS_SSH_MOUSE_REPORTING_DISABLE_SEQUENCE + WINDOWS_MOUSE_REPORTING_DISABLE_SEQUENCE })?; if enabled { writer.write_all(if sgr_pixels { @@ -182,9 +175,11 @@ mod tests { #[test] fn clears_all_known_host_mouse_modes() { - let sequence = std::str::from_utf8(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE).unwrap(); + let mut output = Vec::new(); + clear_host_mouse_reporting(&mut output).unwrap(); + let sequence = std::str::from_utf8(&output).unwrap(); - for mode in ["1000", "1002", "1003", "1005", "1006", "1015", "1016"] { + for mode in ["9", "1000", "1002", "1003", "1005", "1006", "1015", "1016"] { assert!( sequence.contains(&format!("\x1b[?{mode}l")), "missing mouse mode {mode}" @@ -193,12 +188,12 @@ mod tests { } #[test] - fn windows_ssh_mouse_reporting_setup_and_teardown_request_required_modes() { + fn windows_mouse_reporting_setup_and_teardown_request_required_modes() { let mut output = Vec::new(); - set_windows_ssh_mouse_reporting(&mut output, true, true).unwrap(); - set_windows_ssh_mouse_reporting(&mut output, true, false).unwrap(); - set_windows_ssh_mouse_reporting(&mut output, false, false).unwrap(); + set_windows_mouse_reporting(&mut output, true, true).unwrap(); + set_windows_mouse_reporting(&mut output, true, false).unwrap(); + set_windows_mouse_reporting(&mut output, false, false).unwrap(); assert_eq!( output, diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index d472582736..2ece225120 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -241,9 +241,7 @@ pub(crate) fn resolved_token_spans( let previous = &resolved[visible_indices[position - 1]]; spans.push(Span::styled( tokens::separator(previous, token), - Style::default() - .fg(palette.overlay0) - .add_modifier(Modifier::DIM), + Style::default().fg(palette.overlay0), )); } match &token.kind { diff --git a/src/ui/status.rs b/src/ui/status.rs index e47e0f900a..d8371cdb54 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -54,10 +54,10 @@ pub(crate) fn render_copy_feedback_buffer( offset_rows: u16, position: ToastClipboardPosition, palette: &Palette, -) { +) -> Rect { let feedback_area = copy_feedback_rect(area, feedback, offset_rows, position); if feedback_area.is_empty() { - return; + return feedback_area; } Clear.render(feedback_area, buffer); @@ -69,7 +69,7 @@ pub(crate) fn render_copy_feedback_buffer( block.render(feedback_area, buffer); if inner.height == 0 { - return; + return feedback_area; } let text = Line::from(vec![ @@ -84,6 +84,7 @@ pub(crate) fn render_copy_feedback_buffer( ), ]); Paragraph::new(text).render(inner, buffer); + feedback_area } pub(crate) fn render_config_diagnostic_buffer( @@ -91,6 +92,7 @@ pub(crate) fn render_config_diagnostic_buffer( area: Rect, message: &str, palette: &Palette, + mut covered: impl FnMut(Rect), ) -> u16 { let style = Style::default() .fg(panel_contrast_fg(palette)) @@ -113,6 +115,7 @@ pub(crate) fn render_config_diagnostic_buffer( 1, ); + covered(diagnostic_area); Clear.render(diagnostic_area, buffer); Paragraph::new(Span::styled(text, style)).render(diagnostic_area, buffer); rendered_rows = rendered_rows.saturating_add(1); diff --git a/src/update.rs b/src/update.rs index 0b7b3f7802..0870f8dda8 100644 --- a/src/update.rs +++ b/src/update.rs @@ -705,8 +705,7 @@ fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String> Ok(()) } -#[cfg(windows)] -const WINDOWS_INSTALLER: &str = include_str!("../distribution/install.ps1"); +pub(crate) const WINDOWS_INSTALLER: &str = include_str!("../distribution/install.ps1"); #[cfg(windows)] struct DownloadedWindowsUpdate { diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index 5d87f73b9f..5a88769812 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -458,6 +458,7 @@ fn status_commands_report_client_and_server_versions() { assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); assert_eq!(full_json["client"]["protocol"], 22); assert_eq!(full_json["client"]["endpoint_protocol_generation"], 1); + assert_eq!(full_json["client"]["remote_host_bridge"], true); assert_eq!(full_json["server"]["status"], "running"); assert_eq!(full_json["server"]["running"], true); assert_eq!(full_json["server"]["compatible"], true); @@ -482,6 +483,7 @@ fn status_commands_report_client_and_server_versions() { assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); assert_eq!(client_json["protocol"], 22); assert_eq!(client_json["endpoint_protocol_generation"], 1); + assert_eq!(client_json["remote_host_bridge"], true); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 06bd11237e..d3205e5ae5 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -808,11 +808,18 @@ fn federated_launch_opens_local_directly_while_saved_ssh_is_unavailable() { || { read_output(&output).contains("Local") } )); let mut input = client._master.as_ref().unwrap().take_writer().unwrap(); - input - .write_all(b"printf 'LOCAL_%s\\n' DIRECT_READY\r") - .unwrap(); + // Input is gated until Local's active surface is ready, and that readiness can lag + // the first rendered frame (the unavailable remote must not extend the wait). Retry + // the write instead of assuming a single write lands, matching the recovered-Local + // path below. assert!(wait_until(Duration::from_secs(10), Duration::from_millis(20), || { - read_output(&output).contains("LOCAL_DIRECT_READY") + if read_output(&output).contains("LOCAL_DIRECT_READY") { + return true; + } + input + .write_all(b"printf 'LOCAL_%s\\n' DIRECT_READY\r") + .unwrap(); + false }), "Local must accept input without waiting for SSH (remote selected: {select_remote}): {}", read_output(&output)); let text = read_output(&output); assert!(!text.contains("Local: connecting"), "{text}"); @@ -882,9 +889,10 @@ fn federated_client_starts_without_local_and_survives_its_restart() { std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_herdr"), bin.join("herdr")).unwrap(); let quote = |path: &std::path::Path| format!("'{}'", path.display().to_string().replace('\'', "'\\''")); + let ssh_commands = base.join("ssh-commands"); fs::write(bin.join("ssh"), format!( - "#!/bin/sh\nexport HOME={} XDG_CONFIG_HOME={} XDG_RUNTIME_DIR={} HERDR_SOCKET_PATH={}\nunset HERDR_CLIENT_SOCKET_PATH HERDR_SESSION\nfor arg do last=\"$arg\"; done\nexec /bin/sh -c \"$last\"\n", - quote(&base.join("home")), quote(&remote_config), quote(&remote_runtime), quote(&remote_api), + "#!/bin/sh\nexport HOME={} XDG_CONFIG_HOME={} XDG_RUNTIME_DIR={} HERDR_SOCKET_PATH={}\nunset HERDR_CLIENT_SOCKET_PATH HERDR_SESSION\nfor arg do last=\"$arg\"; done\nprintf '%s\\n' \"$last\" >> {}\nexec /bin/sh -c \"$last\"\n", + quote(&base.join("home")), quote(&remote_config), quote(&remote_runtime), quote(&remote_api), quote(&ssh_commands), )).unwrap(); fs::set_permissions(bin.join("ssh"), fs::Permissions::from_mode(0o700)).unwrap(); let path = format!( @@ -907,6 +915,12 @@ fn federated_client_starts_without_local_and_survives_its_restart() { "remote must be usable before Local exists: {}", read_output(&output) ); + assert!( + fs::read_to_string(&ssh_commands) + .unwrap() + .contains("remote-client-bridge --idle-timeout-v1"), + "saved machine discovery must opt into the advertised bridge idle timeout" + ); let mut local = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); wait_for_socket(&api_socket, Duration::from_secs(10)); diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 38e68a5ae5..e40f82683b 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -702,6 +702,79 @@ fn live_handoff_unknown_pane_exit_preserves_session_on_shutdown() { cleanup_test_base(&base); } +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +fn live_handoff_carries_more_panes_than_one_scm_rights_message() { + const PANES: usize = 70; + + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + let server_pid = spawned + .child + .process_id() + .expect("test server should expose pid"); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let workspace_id = created["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + + // One pane per tab keeps the layout shallow, so this exercises the fd + // transfer rather than the depth of a single split tree. + for index in 1..PANES { + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": format!("test:tab:create-{index}"), + "method": "tab.create", + "params": {"workspace_id": workspace_id, "focus": false} + }), + )); + } + wait_for_server_ptmx_fd_count(server_pid, PANES, Duration::from_secs(60)); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + let replacement_pid = + wait_for_replacement_server_pid(&runtime_dir, server_pid, Duration::from_secs(30)); + wait_for_api(&api_socket, Duration::from_secs(30)); + wait_for_server_ptmx_fd_count(replacement_pid, PANES, Duration::from_secs(30)); + + let panes = request( + &api_socket, + serde_json::json!({"id":"test:pane:list","method":"pane.list","params":{}}), + ); + assert_eq!( + panes["result"]["panes"].as_array().map(Vec::len), + Some(PANES), + "replacement server should report every pane after handoff" + ); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + drop(spawned); + cleanup_test_base(&base); +} + #[test] fn live_handoff_preserves_named_session_socket_paths() { let _lock = test_lock(); diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 40645accc6..1059629299 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -89,3 +89,46 @@ LIBGHOSTTY_VT_WINDOWS_LIBC=/path/to/windows-libc.txt just windows-lint LIBGHOSTTY_VT_SIMD=false just test-one ghostty just maintenance-test ``` + +## 0005 bounded word selection for wrapped link activation + +status: active + +patch: `vendor/patches/libghostty-vt/0005-bounded-word-selection.patch` + +herdr issue: https://github.com/herdrdev/herdr/issues/1282 + +upstream discussion: not opened + +upstream pr: not opened; related merged PR https://github.com/ghostty-org/ghostty/pull/10132 +implements URL selection in the application layer, not the libghostty C API. + +vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/selection.h` +- `vendor/libghostty-vt/src/lib_vt.zig` +- `vendor/libghostty-vt/src/terminal/Screen.zig` +- `vendor/libghostty-vt/src/terminal/c/main.zig` +- `vendor/libghostty-vt/src/terminal/c/selection.zig` + +reason: Ctrl+click must resolve a wrapped token beyond the visible viewport +without scanning an arbitrarily long logical line. The new, opt-in API shares +one cell-inspection budget across both directions and returns no selection on +exhaustion, never a truncated link. Its scan skips wide-character spacer cells. +The existing word-selection functions and option layouts remain unchanged; +only Herdr's link activation uses the new function. + +remove when: upstream provides an equivalent bounded, wrap-aware selection API +that handles wide-character spacers, and Herdr passes the tests below using it +without this patch. + +verification: + +```sh +just test-one link_target +just test-one link_activation +just test-one ctrl_click +just check +``` diff --git a/vendor/libghostty-vt/include/ghostty/vt/selection.h b/vendor/libghostty-vt/include/ghostty/vt/selection.h index 0158965e9e..3e557ef05a 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/selection.h +++ b/vendor/libghostty-vt/include/ghostty/vt/selection.h @@ -769,6 +769,21 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_word( const GhosttyTerminalSelectWordOptions* options, GhosttySelection* out_selection); +/** + * Derive a word selection with a shared forward/backward cell inspection limit. + * + * Like ghostty_terminal_select_word(), but returns GHOSTTY_NO_VALUE if + * max_cells is zero or the search exhausts that budget. No partial selection + * is returned. The existing options structure and unbounded API are unchanged. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word_bounded( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordOptions* options, + size_t max_cells, + GhosttySelection* out_selection); + /** * Derive the nearest word selection snapshot between two terminal grid refs. * diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index a0227aef7b..4853656865 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig +++ b/vendor/libghostty-vt/src/lib_vt.zig @@ -331,6 +331,7 @@ comptime { @export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" }); if (features.selection) { @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); + @export(&c.terminal_select_word_bounded, .{ .name = "ghostty_terminal_select_word_bounded" }); @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig index 208e276a2c..d0d2b929fe 100644 --- a/vendor/libghostty-vt/src/terminal/Screen.zig +++ b/vendor/libghostty-vt/src/terminal/Screen.zig @@ -3350,6 +3350,111 @@ pub fn selectWord( return .init(start, end, false); } +/// Like selectWord, but returns null instead of a partial selection when the +/// shared forward/backward cell inspection budget is exhausted. +pub fn selectWordBounded( + self: *Screen, + pin: Pin, + boundary_codepoints: []const u21, + max_cells: usize, +) ?Selection { + _ = self; + if (max_cells == 0) return null; + var remaining = max_cells - 1; + + // If our cell is empty we can't select a word, because we can't select + // areas where the screen is not yet written. + const start_cell = pin.rowAndCell().cell; + if (!start_cell.hasText()) return null; + + // Determine if we are a boundary or not to determine what our boundary is. + const expect_boundary = std.mem.indexOfScalar( + u21, + boundary_codepoints, + start_cell.content.codepoint.data, + ) != null; + + // Go forwards to find our end boundary + const end: Pin = end: { + var it = pin.cellIterator(.right_down, null); + var prev = it.next().?; // Consume one, our start + while (it.next()) |p| { + if (remaining == 0) return null; + remaining -= 1; + const rac = p.rowAndCell(); + const cell = rac.cell; + + // Wide-cell spacers are part of the same displayed word. + if (cell.wide == .spacer_tail or cell.wide == .spacer_head) { + prev = p; + if (p.x == p.node.cols() - 1 and !rac.row.wrap) break :end p; + continue; + } + + // If we reached an empty cell its always a boundary + if (!cell.hasText()) break :end prev; + + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfScalar( + u21, + boundary_codepoints, + cell.content.codepoint.data, + ) != null; + if (this_boundary != expect_boundary) break :end prev; + + // If we are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { + break :end p; + } + + prev = p; + } + + break :end prev; + }; + + // Go backwards to find our start boundary + const start: Pin = start: { + var it = pin.cellIterator(.left_up, null); + var prev = it.next().?; // Consume one, our start + while (it.next()) |p| { + if (remaining == 0) return null; + remaining -= 1; + const rac = p.rowAndCell(); + const cell = rac.cell; + + // If we are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { + break :start prev; + } + + if (cell.wide == .spacer_tail or cell.wide == .spacer_head) { + prev = p; + continue; + } + + // If we reached an empty cell its always a boundary + if (!cell.hasText()) break :start prev; + + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfScalar( + u21, + boundary_codepoints, + cell.content.codepoint.data, + ) != null; + if (this_boundary != expect_boundary) break :start prev; + + prev = p; + } + + break :start prev; + }; + + return .init(start, end, false); +} + /// Select the command output under the given point. The limits of the output /// are determined by semantic prompt information provided by shell integration. /// A selection can span multiple physical lines if they are soft-wrapped. diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 45b4dc47f8..0ba1224a5b 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -196,6 +196,7 @@ pub const terminal_continuation_write = terminal.continuation_write; pub const terminal_continuation_buf = terminal.continuation_buf; pub const terminal_continuation_alloc = terminal.continuation_alloc; pub const terminal_select_word = selection.word; +pub const terminal_select_word_bounded = selection.word_bounded; pub const terminal_select_word_between = selection.word_between; pub const terminal_select_line = selection.line; pub const terminal_select_all = selection.all; diff --git a/vendor/libghostty-vt/src/terminal/c/selection.zig b/vendor/libghostty-vt/src/terminal/c/selection.zig index 2830149f74..63873bed99 100644 --- a/vendor/libghostty-vt/src/terminal/c/selection.zig +++ b/vendor/libghostty-vt/src/terminal/c/selection.zig @@ -110,6 +110,33 @@ pub fn word( return .success; } +pub fn word_bounded( + terminal: terminal_c.Terminal, + options: ?*const SelectWordOptions, + max_cells: usize, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const opts = options orelse return .invalid_value; + if (opts.size < @sizeOf(SelectWordOptions)) return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const boundary_codepoints = codepointSlice( + opts.boundary_codepoints, + opts.boundary_codepoints_len, + ) catch return .invalid_value; + + const screen = t.screens.active; + const pin = opts.ref.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectWordBounded( + pin, + boundary_codepoints orelse &selection_codepoints.default_word_boundaries, + max_cells, + ) orelse + return .no_value); + return .success; +} + pub fn word_between( terminal: terminal_c.Terminal, options: ?*const SelectWordBetweenOptions, diff --git a/vendor/patches/libghostty-vt/0005-bounded-word-selection.patch b/vendor/patches/libghostty-vt/0005-bounded-word-selection.patch new file mode 100644 index 0000000000..614d962f12 --- /dev/null +++ b/vendor/patches/libghostty-vt/0005-bounded-word-selection.patch @@ -0,0 +1,204 @@ +diff --git a/vendor/libghostty-vt/include/ghostty/vt/selection.h b/vendor/libghostty-vt/include/ghostty/vt/selection.h +index 0158965e..3e557ef0 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/selection.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/selection.h +@@ -769,6 +769,21 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_word( + const GhosttyTerminalSelectWordOptions* options, + GhosttySelection* out_selection); + ++/** ++ * Derive a word selection with a shared forward/backward cell inspection limit. ++ * ++ * Like ghostty_terminal_select_word(), but returns GHOSTTY_NO_VALUE if ++ * max_cells is zero or the search exhausts that budget. No partial selection ++ * is returned. The existing options structure and unbounded API are unchanged. ++ * ++ * @ingroup selection ++ */ ++GHOSTTY_API GhosttyResult ghostty_terminal_select_word_bounded( ++ GhosttyTerminal terminal, ++ const GhosttyTerminalSelectWordOptions* options, ++ size_t max_cells, ++ GhosttySelection* out_selection); ++ + /** + * Derive the nearest word selection snapshot between two terminal grid refs. + * +diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig +index a0227aef..48536568 100644 +--- a/vendor/libghostty-vt/src/lib_vt.zig ++++ b/vendor/libghostty-vt/src/lib_vt.zig +@@ -331,6 +331,7 @@ comptime { + @export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" }); + if (features.selection) { + @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); ++ @export(&c.terminal_select_word_bounded, .{ .name = "ghostty_terminal_select_word_bounded" }); + @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); + @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); + @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); +diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig +index 208e276a..d0d2b929 100644 +--- a/vendor/libghostty-vt/src/terminal/Screen.zig ++++ b/vendor/libghostty-vt/src/terminal/Screen.zig +@@ -3350,6 +3350,111 @@ pub fn selectWord( + return .init(start, end, false); + } + ++/// Like selectWord, but returns null instead of a partial selection when the ++/// shared forward/backward cell inspection budget is exhausted. ++pub fn selectWordBounded( ++ self: *Screen, ++ pin: Pin, ++ boundary_codepoints: []const u21, ++ max_cells: usize, ++) ?Selection { ++ _ = self; ++ if (max_cells == 0) return null; ++ var remaining = max_cells - 1; ++ ++ // If our cell is empty we can't select a word, because we can't select ++ // areas where the screen is not yet written. ++ const start_cell = pin.rowAndCell().cell; ++ if (!start_cell.hasText()) return null; ++ ++ // Determine if we are a boundary or not to determine what our boundary is. ++ const expect_boundary = std.mem.indexOfScalar( ++ u21, ++ boundary_codepoints, ++ start_cell.content.codepoint.data, ++ ) != null; ++ ++ // Go forwards to find our end boundary ++ const end: Pin = end: { ++ var it = pin.cellIterator(.right_down, null); ++ var prev = it.next().?; // Consume one, our start ++ while (it.next()) |p| { ++ if (remaining == 0) return null; ++ remaining -= 1; ++ const rac = p.rowAndCell(); ++ const cell = rac.cell; ++ ++ // Wide-cell spacers are part of the same displayed word. ++ if (cell.wide == .spacer_tail or cell.wide == .spacer_head) { ++ prev = p; ++ if (p.x == p.node.cols() - 1 and !rac.row.wrap) break :end p; ++ continue; ++ } ++ ++ // If we reached an empty cell its always a boundary ++ if (!cell.hasText()) break :end prev; ++ ++ // If we do not match our expected set, we hit a boundary ++ const this_boundary = std.mem.indexOfScalar( ++ u21, ++ boundary_codepoints, ++ cell.content.codepoint.data, ++ ) != null; ++ if (this_boundary != expect_boundary) break :end prev; ++ ++ // If we are going to the next row and it isn't wrapped, we ++ // return the previous. ++ if (p.x == p.node.cols() - 1 and !rac.row.wrap) { ++ break :end p; ++ } ++ ++ prev = p; ++ } ++ ++ break :end prev; ++ }; ++ ++ // Go backwards to find our start boundary ++ const start: Pin = start: { ++ var it = pin.cellIterator(.left_up, null); ++ var prev = it.next().?; // Consume one, our start ++ while (it.next()) |p| { ++ if (remaining == 0) return null; ++ remaining -= 1; ++ const rac = p.rowAndCell(); ++ const cell = rac.cell; ++ ++ // If we are going to the next row and it isn't wrapped, we ++ // return the previous. ++ if (p.x == p.node.cols() - 1 and !rac.row.wrap) { ++ break :start prev; ++ } ++ ++ if (cell.wide == .spacer_tail or cell.wide == .spacer_head) { ++ prev = p; ++ continue; ++ } ++ ++ // If we reached an empty cell its always a boundary ++ if (!cell.hasText()) break :start prev; ++ ++ // If we do not match our expected set, we hit a boundary ++ const this_boundary = std.mem.indexOfScalar( ++ u21, ++ boundary_codepoints, ++ cell.content.codepoint.data, ++ ) != null; ++ if (this_boundary != expect_boundary) break :start prev; ++ ++ prev = p; ++ } ++ ++ break :start prev; ++ }; ++ ++ return .init(start, end, false); ++} ++ + /// Select the command output under the given point. The limits of the output + /// are determined by semantic prompt information provided by shell integration. + /// A selection can span multiple physical lines if they are soft-wrapped. +diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig +index 45b4dc47..0ba1224a 100644 +--- a/vendor/libghostty-vt/src/terminal/c/main.zig ++++ b/vendor/libghostty-vt/src/terminal/c/main.zig +@@ -196,6 +196,7 @@ pub const terminal_continuation_write = terminal.continuation_write; + pub const terminal_continuation_buf = terminal.continuation_buf; + pub const terminal_continuation_alloc = terminal.continuation_alloc; + pub const terminal_select_word = selection.word; ++pub const terminal_select_word_bounded = selection.word_bounded; + pub const terminal_select_word_between = selection.word_between; + pub const terminal_select_line = selection.line; + pub const terminal_select_all = selection.all; +diff --git a/vendor/libghostty-vt/src/terminal/c/selection.zig b/vendor/libghostty-vt/src/terminal/c/selection.zig +index 2830149f..63873bed 100644 +--- a/vendor/libghostty-vt/src/terminal/c/selection.zig ++++ b/vendor/libghostty-vt/src/terminal/c/selection.zig +@@ -110,6 +110,33 @@ pub fn word( + return .success; + } + ++pub fn word_bounded( ++ terminal: terminal_c.Terminal, ++ options: ?*const SelectWordOptions, ++ max_cells: usize, ++ out_selection: ?*CSelection, ++) callconv(lib.calling_conv) Result { ++ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; ++ const opts = options orelse return .invalid_value; ++ if (opts.size < @sizeOf(SelectWordOptions)) return .invalid_value; ++ const out = out_selection orelse return .invalid_value; ++ ++ const boundary_codepoints = codepointSlice( ++ opts.boundary_codepoints, ++ opts.boundary_codepoints_len, ++ ) catch return .invalid_value; ++ ++ const screen = t.screens.active; ++ const pin = opts.ref.toPin() orelse return .invalid_value; ++ out.* = .fromZig(screen.selectWordBounded( ++ pin, ++ boundary_codepoints orelse &selection_codepoints.default_word_boundaries, ++ max_cells, ++ ) orelse ++ return .no_value); ++ return .success; ++} ++ + pub fn word_between( + terminal: terminal_c.Terminal, + options: ?*const SelectWordBetweenOptions,